@applitools/core 4.60.0 → 4.61.1-debug.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.
@@ -26,12 +26,23 @@ 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.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
33
  const logger_1 = require("@applitools/logger");
34
- // Check if debug mode is enabled for verbose git info logging
34
+ /**
35
+ * Gate for verbose, deep-dive diagnostic logging in this file.
36
+ *
37
+ * Toggled by GitHub Actions' standard `RUNNER_DEBUG=1` env var (also settable
38
+ * locally for reproduction). When OFF, only high-signal log lines emit:
39
+ * - which fallback path was taken
40
+ * - errors and unusual states
41
+ * - the final extracted value
42
+ * When ON, every per-branch / per-commit decision, every retry, and every
43
+ * `[PERF]` timing is also logged. Use this guard for anything noisy enough
44
+ * to bury the signal in CI logs.
45
+ */
35
46
  const isDebugMode = () => process.env.RUNNER_DEBUG === '1';
36
47
  exports.cacheKey = 'default';
37
48
  /**
@@ -54,7 +65,266 @@ exports.getPrimaryRemoteName = utils.general.cachify(async function ({ execOptio
54
65
  cwd: (_a = args[0].execOptions) === null || _a === void 0 ? void 0 : _a.cwd,
55
66
  });
56
67
  });
68
+ /**
69
+ * One-shot, cached probe of the full git environment, staged from cheapest
70
+ * to most expensive:
71
+ * 1. is `git` installed and on PATH? (`git --version`)
72
+ * 2. are we inside a git work tree? (`git rev-parse --is-inside-work-tree`)
73
+ * 3. is a primary remote configured? (`git remote`)
74
+ * 4. is that remote reachable? (`git ls-remote <r> HEAD`)
75
+ *
76
+ * Use this as the outermost guard for any code path that shells out to git.
77
+ * Each step short-circuits later ones: missing binary skips repo/remote
78
+ * checks; non-repo skips remote checks; no remote configured skips the
79
+ * reachability probe. Callers should consult `ok` for local-only git
80
+ * features and `remoteAccessible` for anything that talks to the network.
81
+ *
82
+ * Cached per `cwd`. The first invocation emits a clearly-visible banner so
83
+ * support can grep one distinctive line — `APPLITOOLS GIT ENVIRONMENT CHECK`
84
+ * — and immediately tell which sub-check failed. Subsequent invocations
85
+ * return the cached verdict silently.
86
+ */
87
+ exports.checkGitEnvironment = utils.general.cachify(async function ({ execOptions, logger = (0, logger_1.makeLogger)(), }) {
88
+ // INVARIANT: this function MUST NOT reject. It is cachified, so a rejected
89
+ // promise would be memoized and every one of the 11 downstream callers
90
+ // would re-throw on every subsequent call. Every path below either
91
+ // returns via `finalize()` (which itself can't throw — see below) or is
92
+ // caught by the outermost safety net at the bottom.
93
+ var _a, _b, _c, _d, _e, _f;
94
+ let gitInstalled = false;
95
+ let insideRepo = false;
96
+ let remoteAccessible = false;
97
+ let remoteName;
98
+ const reasons = [];
99
+ /**
100
+ * Build the result, emit the banner, and return. Defensive: even if the
101
+ * underlying logger throws (e.g. a wrapped transport with a bug), the
102
+ * banner call is swallowed so this function still returns a usable
103
+ * `GitEnvironmentResult`.
104
+ */
105
+ const finalize = (extraReason) => {
106
+ if (extraReason)
107
+ reasons.push(extraReason);
108
+ const result = {
109
+ gitInstalled,
110
+ insideRepo,
111
+ remoteAccessible,
112
+ ok: gitInstalled && insideRepo,
113
+ reason: reasons.length ? reasons.join('; ') : 'ok',
114
+ remoteName,
115
+ };
116
+ try {
117
+ logGitEnvironmentBanner(logger, result);
118
+ }
119
+ catch {
120
+ // Logger failures must NEVER make the env check itself fail.
121
+ }
122
+ return result;
123
+ };
124
+ try {
125
+ try {
126
+ // 1. Is git itself installed? `git --version` is the cheapest sanity check.
127
+ // Non-fatal: any failure here returns via `finalize` with a clean result.
128
+ const versionResult = await executeWithLog('git --version', { execOptions, logger });
129
+ if (versionResult.code !== 0) {
130
+ return finalize(`git binary: ${((_a = versionResult.stderr) === null || _a === void 0 ? void 0 : _a.trim()) || `exit ${versionResult.code} (command not found?)`}`);
131
+ }
132
+ gitInstalled = true;
133
+ // 2. Are we inside a git work tree? Non-fatal — falls through to finalize.
134
+ const repoResult = await executeWithLog('git rev-parse --is-inside-work-tree', { execOptions, logger });
135
+ if (repoResult.code !== 0 || repoResult.stdout.trim() !== 'true') {
136
+ return finalize(`repo: ${((_b = repoResult.stderr) === null || _b === void 0 ? void 0 : _b.trim()) || 'not inside a git work tree'}`);
137
+ }
138
+ insideRepo = true;
139
+ // 3. Is any remote configured? Non-fatal — falls through to finalize.
140
+ const remotesResult = await executeWithLog('git remote', { execOptions, logger });
141
+ const remotesText = remotesResult.stdout.trim();
142
+ if (remotesResult.code !== 0 || !remotesText) {
143
+ return finalize(`remote: ${((_c = remotesResult.stderr) === null || _c === void 0 ? void 0 : _c.trim()) || 'no remote configured'}`);
144
+ }
145
+ const remotes = remotesText.split(/\s+/).filter(Boolean);
146
+ // `remotes` always has ≥1 element here because `remotesText` is non-empty
147
+ // and split+filter on a non-empty string yields at least one element.
148
+ remoteName = remotes.includes('origin') ? 'origin' : remotes[0];
149
+ // 4. Can we reach the remote? Non-fatal — falls through to finalize.
150
+ const probe = await executeWithLog(`git ls-remote ${remoteName} HEAD`, { execOptions, logger });
151
+ if (probe.code !== 0) {
152
+ return finalize(`remote: ${((_d = probe.stderr) === null || _d === void 0 ? void 0 : _d.trim()) || `ls-remote exit ${probe.code}`}`);
153
+ }
154
+ remoteAccessible = true;
155
+ return finalize();
156
+ }
157
+ catch (err) {
158
+ // Catches `executeWithLog` rejections (e.g. spawn ENOENT when the git
159
+ // binary is missing entirely). Falls through to a clean finalize.
160
+ return finalize(`probe threw: ${(_e = err === null || err === void 0 ? void 0 : err.message) !== null && _e !== void 0 ? _e : err}`);
161
+ }
162
+ }
163
+ catch (catastrophic) {
164
+ // Absolute last-resort safety net. Should never fire — `finalize` is
165
+ // designed not to throw — but if anything truly unexpected happens
166
+ // (out-of-memory, logger-throw-past-our-guard, etc.), return a
167
+ // synthetic "all-failed" result rather than rejecting the promise.
168
+ return {
169
+ gitInstalled: false,
170
+ insideRepo: false,
171
+ remoteAccessible: false,
172
+ ok: false,
173
+ reason: `checkGitEnvironment catastrophic failure: ${(_f = catastrophic === null || catastrophic === void 0 ? void 0 : catastrophic.message) !== null && _f !== void 0 ? _f : catastrophic}`,
174
+ remoteName: undefined,
175
+ };
176
+ }
177
+ }, 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 }); });
178
+ /**
179
+ * Unified, grep-friendly banner. The `APPLITOOLS GIT ENVIRONMENT CHECK`
180
+ * prefix is unique to this codepath, so support can find one line in any
181
+ * log and read off the entire environment verdict at a glance.
182
+ */
183
+ function logGitEnvironmentBanner(logger, r) {
184
+ const sep = '============================================================';
185
+ const allGood = r.gitInstalled && r.insideRepo && r.remoteAccessible;
186
+ // Header — names the first failed sub-check (or "all ok").
187
+ logger.log(sep);
188
+ if (allGood) {
189
+ logger.log('APPLITOOLS GIT ENVIRONMENT CHECK ✓ all ok');
190
+ }
191
+ else if (!r.gitInstalled) {
192
+ logger.log('APPLITOOLS GIT ENVIRONMENT CHECK ✗ GIT NOT INSTALLED');
193
+ }
194
+ else if (!r.insideRepo) {
195
+ logger.log('APPLITOOLS GIT ENVIRONMENT CHECK ✗ NOT IN A GIT REPOSITORY');
196
+ }
197
+ else if (!r.remoteAccessible) {
198
+ logger.log('APPLITOOLS GIT ENVIRONMENT CHECK ✗ REMOTE NOT ACCESSIBLE');
199
+ }
200
+ // Per-condition status lines — show every sub-check that was reached so
201
+ // a reader can see at a glance which stage went wrong.
202
+ logger.log(` git binary: ${r.gitInstalled ? '✓ installed' : '✗ NOT FOUND'}`);
203
+ if (r.gitInstalled) {
204
+ logger.log(` repo: ${r.insideRepo ? '✓ inside work tree' : '✗ NOT INSIDE A WORK TREE'}`);
205
+ }
206
+ if (r.gitInstalled && r.insideRepo) {
207
+ const remoteLine = r.remoteAccessible
208
+ ? `✓ accessible (${r.remoteName})`
209
+ : r.remoteName
210
+ ? `✗ NOT ACCESSIBLE (${r.remoteName})`
211
+ : '✗ NONE CONFIGURED';
212
+ logger.log(` remote: ${remoteLine}`);
213
+ }
214
+ // Reason + remediation hints — only for the failure mode that triggered.
215
+ if (!allGood) {
216
+ logger.log(` reason: ${r.reason}`);
217
+ if (!r.gitInstalled) {
218
+ logger.log(' → All git-based SCM features will be SKIPPED.');
219
+ logger.log(' → Install git or add it to PATH to enable branch detection and baseline inheritance.');
220
+ }
221
+ else if (!r.insideRepo) {
222
+ logger.log(' → All git-based SCM features will be SKIPPED.');
223
+ logger.log(' → Run this from inside a git working tree to enable branch detection.');
224
+ }
225
+ else if (!r.remoteAccessible) {
226
+ logger.log(' → All remote-dependent fallbacks will be SKIPPED.');
227
+ logger.log(' → Branch lookup may be incomplete. Verify network / credentials / remote config.');
228
+ }
229
+ }
230
+ logger.log(sep);
231
+ }
232
+ /**
233
+ * Programmatically detect the default branch of the current repository.
234
+ *
235
+ * Fallback chain (each step short-circuits on success; never throws):
236
+ * 1. `APPLITOOLS_DEFAULT_BRANCH` env var — explicit user override.
237
+ * 2. `git symbolic-ref --short refs/remotes/<remote>/HEAD` — authoritative
238
+ * source on any clone that has fetched at least once. Returns e.g.
239
+ * `origin/master`; the `<remote>/` prefix is stripped.
240
+ * 3. `git ls-remote --symref <remote> HEAD` — works even when the local
241
+ * `refs/remotes/<remote>/HEAD` symref was never set up (single-branch
242
+ * clones, older git on CI).
243
+ * 4. `git config --get init.defaultBranch` — honors a user-global preference
244
+ * if everything else fails.
245
+ * 5. Literal `'main'` — last resort.
246
+ *
247
+ * Steps 2 and 3 catch non-standard defaults (e.g. JFrog's
248
+ * `JFROG/artifactory-mfe/preRelease/jfmfe-1.995.x-rc`). Cached per `cwd`
249
+ * to match `getPrimaryRemoteName`.
250
+ */
251
+ exports.extractDefaultBranch = utils.general.cachify(async function ({ execOptions, logger = (0, logger_1.makeLogger)() }) {
252
+ var _a;
253
+ // 1. Explicit env override — works even without git installed / outside a repo.
254
+ const envOverride = (_a = process.env.APPLITOOLS_DEFAULT_BRANCH) === null || _a === void 0 ? void 0 : _a.trim();
255
+ if (envOverride) {
256
+ logger.log(`extractDefaultBranch: using APPLITOOLS_DEFAULT_BRANCH="${envOverride}"`);
257
+ return envOverride;
258
+ }
259
+ // Function-entry env gate. All remaining steps (2–4) shell out to git;
260
+ // if git isn't installed or we're not in a repo, skip straight to 'main'.
261
+ const env = await (0, exports.checkGitEnvironment)({ execOptions, logger });
262
+ if (!env.ok) {
263
+ logger.log("extractDefaultBranch: git environment not ok, falling back to 'main'");
264
+ return 'main';
265
+ }
266
+ const remoteName = await (0, exports.getPrimaryRemoteName)({ execOptions, logger });
267
+ // 2. git symbolic-ref --short refs/remotes/<remote>/HEAD
268
+ try {
269
+ const r = await executeWithLog(`git symbolic-ref --short refs/remotes/${remoteName}/HEAD`, {
270
+ execOptions,
271
+ logger,
272
+ });
273
+ if (!r.stderr && r.stdout.trim()) {
274
+ const out = r.stdout.trim();
275
+ const normalized = out.replace(new RegExp(`^${remoteName}/`), '');
276
+ if (normalized) {
277
+ logger.log(`extractDefaultBranch: resolved via symbolic-ref -> "${normalized}"`);
278
+ return normalized;
279
+ }
280
+ }
281
+ }
282
+ catch (err) {
283
+ // Expected when the symref isn't set up (e.g. fresh single-branch clone). Verbose-only.
284
+ if (isDebugMode())
285
+ logger.log('extractDefaultBranch: symbolic-ref step failed, continuing', err);
286
+ }
287
+ // 3. git ls-remote --symref <remote> HEAD
288
+ try {
289
+ const r = await executeWithLog(`git ls-remote --symref ${remoteName} HEAD`, { execOptions, logger });
290
+ if (!r.stderr && r.stdout) {
291
+ // Output starts with a line like: "ref: refs/heads/<name>\tHEAD"
292
+ const match = r.stdout.match(/^ref:\s+refs\/heads\/([^\s]+)\s+HEAD/m);
293
+ if (match && match[1]) {
294
+ logger.log(`extractDefaultBranch: resolved via ls-remote --symref -> "${match[1]}"`);
295
+ return match[1];
296
+ }
297
+ }
298
+ }
299
+ catch (err) {
300
+ // Expected on networks without remote access. Verbose-only.
301
+ if (isDebugMode())
302
+ logger.log('extractDefaultBranch: ls-remote --symref step failed, continuing', err);
303
+ }
304
+ // 4. git config --get init.defaultBranch (user-global preference)
305
+ try {
306
+ const r = await executeWithLog('git config --get init.defaultBranch', { execOptions, logger });
307
+ if (!r.stderr && r.stdout.trim()) {
308
+ logger.log(`extractDefaultBranch: resolved via init.defaultBranch -> "${r.stdout.trim()}"`);
309
+ return r.stdout.trim();
310
+ }
311
+ }
312
+ catch (err) {
313
+ // No user-global preference set — totally normal. Verbose-only.
314
+ if (isDebugMode())
315
+ logger.log('extractDefaultBranch: init.defaultBranch step failed, continuing', err);
316
+ }
317
+ // 5. Last resort
318
+ logger.log('extractDefaultBranch: falling back to literal "main"');
319
+ return 'main';
320
+ }, 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 }); });
57
321
  exports.extractLatestCommitInfo = utils.general.cachify(async function ({ execOptions, logger = (0, logger_1.makeLogger)(), }) {
322
+ // Function-entry env gate. Without git or a repo, there is no commit to extract.
323
+ const env = await (0, exports.checkGitEnvironment)({ execOptions, logger });
324
+ if (!env.ok) {
325
+ logger.log('extractLatestCommitInfo: git environment not ok, skipping');
326
+ return undefined;
327
+ }
58
328
  let result;
59
329
  try {
60
330
  const githubPullRequestLastCommitSha = await extractGithubPullRequestLastCommitSha();
@@ -90,11 +360,18 @@ exports.extractLatestCommitInfo = utils.general.cachify(async function ({ execOp
90
360
  }, () => exports.cacheKey);
91
361
  exports.extractGitBranch = utils.general.cachify(async function ({ execOptions, logger = (0, logger_1.makeLogger)() }) {
92
362
  // 1. Try CI environment variables first (fast, no subprocess, handles detached HEAD in CI)
363
+ // This step works even without git installed / outside a repo.
93
364
  const ciBranch = extractCIBranchName();
94
365
  if (ciBranch) {
95
366
  logger.log(`Extracted branch name from CI environment: "${ciBranch}"`);
96
367
  return ciBranch;
97
368
  }
369
+ // Function-entry env gate for the git-using fallback path below.
370
+ const env = await (0, exports.checkGitEnvironment)({ execOptions, logger });
371
+ if (!env.ok) {
372
+ logger.log('extractGitBranch: git environment not ok, no branch name available');
373
+ return undefined;
374
+ }
98
375
  // 2. Fall back to git command (works when on an actual branch locally)
99
376
  const result = await executeWithLog('git branch --show-current', { execOptions, logger });
100
377
  if (result.stderr) {
@@ -110,6 +387,12 @@ exports.extractGitBranch = utils.general.cachify(async function ({ execOptions,
110
387
  }
111
388
  }, 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
389
  exports.extractGitRepo = utils.general.cachify(async function ({ execOptions, logger = (0, logger_1.makeLogger)() }) {
390
+ // Function-entry env gate. Without git or a repo, we can't extract a remote URL.
391
+ const env = await (0, exports.checkGitEnvironment)({ execOptions, logger });
392
+ if (!env.ok) {
393
+ logger.log('extractGitRepo: git environment not ok, returning empty repo info');
394
+ return {};
395
+ }
113
396
  const remotes = await extractRemotes();
114
397
  logger.log(`Extracted remotes from git: ${remotes}`);
115
398
  const remote = remotes.includes('origin') ? 'origin' : remotes[0];
@@ -160,11 +443,43 @@ async function extractBuildIdFromCI() {
160
443
  );
161
444
  }
162
445
  exports.extractBuildIdFromCI = extractBuildIdFromCI;
446
+ /**
447
+ * Extract the ISO-8601 timestamp at which `branchName` branched off from
448
+ * `parentBranchName`.
449
+ *
450
+ * Algorithm (each step short-circuits on success; returns `undefined` on
451
+ * total failure — never throws):
452
+ * 1. If the repo is a shallow clone, unshallow (or treeless-fetch) so the
453
+ * branch topology is visible to `git merge-base`.
454
+ * 2. `git merge-base <remote>/<branch> <remote>/<parent>` — CI typically
455
+ * operates on remote refs only, so try those first.
456
+ * 3. Fall back to local refs (`git merge-base <branch> <parent>`).
457
+ * 4. If still no merge-base, see if the parent exists on the remote and,
458
+ * if so, fetch it by name (`--filter=tree:0`) and retry. If the parent
459
+ * isn't on the remote at all, give up — we can't compute a branching
460
+ * point for a branch that doesn't exist anywhere.
461
+ * 5. Direct-ancestor refinement: if the merge-base equals the parent
462
+ * branch's tip, the parent hasn't diverged from `branchName` yet
463
+ * (parent is a strict ancestor). The "branching point" is then the
464
+ * parent commit of the first child-only commit — not the merge-base
465
+ * itself, which would be the parent's tip and produce a too-recent
466
+ * timestamp.
467
+ * 6. Resolve the final hash's committer-date via `git show -s --format=%aI`.
468
+ *
469
+ * Cached per (branchName, parentBranchName, cwd).
470
+ */
163
471
  exports.extractBranchingTimestamp = utils.general.cachify(async function ({ branchName, parentBranchName, execOptions, logger = (0, logger_1.makeLogger)(), }) {
164
- var _a;
165
472
  logger = logger.extend({ tags: [`extract-branching-timestamp-${utils.general.shortid()}`] });
166
- // Get the primary remote name (cached)
473
+ // Function-entry env gate. Without git or a repo, merge-base is impossible.
474
+ const env = await (0, exports.checkGitEnvironment)({ execOptions, logger });
475
+ if (!env.ok) {
476
+ logger.log('extractBranchingTimestamp: git environment not ok, skipping');
477
+ return undefined;
478
+ }
479
+ // Cached lookup — resolves to `origin` on a typical clone.
167
480
  const remoteName = await (0, exports.getPrimaryRemoteName)({ execOptions, logger });
481
+ // Step 1: ensure topology is visible. Shallow clones don't have enough
482
+ // history for merge-base to find a common ancestor.
168
483
  const shallowCheckResult = await executeWithLog('git rev-parse --is-shallow-repository', {
169
484
  execOptions,
170
485
  logger,
@@ -174,53 +489,94 @@ exports.extractBranchingTimestamp = utils.general.cachify(async function ({ bran
174
489
  logger.log('extractBranchingTimestamp - Repository is a shallow clone, attempting to unshallow');
175
490
  await executeFetchStrategy(isShallow, execOptions, logger);
176
491
  }
177
- // Step 1: Try with remote refs first (fast path - uses already-fetched remote data)
178
- const command = `HASH=$(git merge-base ${branchName} ${parentBranchName}) && git show -q --format=%aI $HASH`;
179
- let result = await executeWithLog(command, { execOptions, logger });
180
- // Step 2: If remote refs failed, try local refs
181
- if (result.stderr) {
182
- const commandWithRemoteRefs = `HASH=$(git merge-base ${remoteName}/${branchName} ${remoteName}/${parentBranchName}) && git show -q --format=%aI $HASH`;
183
- result = await executeWithLog(commandWithRemoteRefs, { execOptions, logger });
492
+ // Helper: best common ancestor SHA between two refs, or null on git failure.
493
+ // `null` is expected during the fallback chain (e.g. first attempt fails
494
+ // because the remote ref isn't fetched), so the log line is verbose-only.
495
+ async function getMergeBase(ref1, ref2) {
496
+ const { stdout, stderr } = await executeWithLog(`git merge-base ${ref1} ${ref2}`, { execOptions, logger });
497
+ if (stderr) {
498
+ if (isDebugMode())
499
+ logger.log(`getMergeBase failed for ${ref1} and ${ref2}: ${stderr}`);
500
+ return null;
501
+ }
502
+ return stdout.trim();
184
503
  }
185
- // Step 3: Handle missing branches with smart fetch (check remote existence first)
186
- // Fetch remote branches list once if there's an error (cached call - virtually free)
187
- if (result.stderr) {
504
+ // Helper: resolve a commit hash to its committer-date in ISO-8601, or
505
+ // undefined on failure. `%aI` is the author-date in strict ISO format.
506
+ async function getTimestampForHash(hash) {
507
+ if (!hash)
508
+ return undefined;
509
+ const { stdout, stderr } = await executeWithLog(`git show -s --format=%aI ${hash}`, { execOptions, logger });
510
+ const timestamp = stdout.trim();
511
+ if (stderr || !isISODate(timestamp)) {
512
+ logger.log(`Error extracting timestamp for hash ${hash}: stderr: ${stderr}, stdout: ${stdout}`);
513
+ return undefined;
514
+ }
515
+ return timestamp;
516
+ }
517
+ // Steps 2–4: resolve the merge-base, escalating from remote → local → fetch.
518
+ let mergeBaseHash = null;
519
+ // Step 2: remote refs (preferred — CI typically has these populated).
520
+ mergeBaseHash = await getMergeBase(`${remoteName}/${branchName}`, `${remoteName}/${parentBranchName}`);
521
+ // Step 3: local refs fallback.
522
+ if (!mergeBaseHash) {
523
+ mergeBaseHash = await getMergeBase(branchName, parentBranchName);
524
+ }
525
+ // Step 4: still no merge-base — fetch the parent by name and retry, but
526
+ // only if it actually exists on the remote (no point fetching a branch
527
+ // that was never pushed). `getAllRemoteBranches` is the natural guard
528
+ // here: when the remote is unreachable it returns an empty set, so the
529
+ // `.has(parentBranchName)` check falls through to "not on remote".
530
+ if (!mergeBaseHash) {
188
531
  const remoteBranches = await getAllRemoteBranches({ execOptions, logger });
189
- // Both parent and current branches could be missing, iterate up to twice
190
- for (let i = 0; i < 2; i++) {
191
- if (result.stderr) {
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
- }
214
- }
532
+ if (remoteBranches.has(parentBranchName)) {
533
+ logger.log(`Fetching missing branch ${parentBranchName} from remote and retrying merge-base`);
534
+ await executeWithLog(`git fetch ${remoteName} ${parentBranchName}:${parentBranchName} --filter=tree:0`, {
535
+ execOptions,
536
+ logger,
537
+ });
538
+ mergeBaseHash = await getMergeBase(branchName, parentBranchName);
539
+ }
540
+ else {
541
+ logger.log(`Parent branch ${parentBranchName} not found on remote, cannot determine branching point.`);
542
+ return undefined;
215
543
  }
216
544
  }
217
- const timestamp = result.stdout.replace(/\s/g, '');
218
- if (isISODate(timestamp)) {
219
- logger.log('git branching timestamp successfully extracted', timestamp);
220
- return timestamp;
545
+ if (!mergeBaseHash) {
546
+ logger.log(`Could not find a merge-base for ${branchName} and ${parentBranchName}.`);
547
+ return undefined;
221
548
  }
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}`);
549
+ // Step 5: direct-ancestor refinement.
550
+ // Resolve both branch tips so we can compare against the merge-base.
551
+ const branchNameSha = (await executeWithLog(`git rev-parse ${branchName}`, { execOptions, logger })).stdout.trim();
552
+ const parentBranchNameSha = (await executeWithLog(`git rev-parse ${parentBranchName}`, { execOptions, logger })).stdout.trim();
553
+ let finalHash = mergeBaseHash;
554
+ // If merge-base == parent's tip, parent is a strict ancestor of branchName
555
+ // (no divergence yet). Using merge-base directly would return the parent's
556
+ // tip timestamp; we want the moment of divergence instead.
557
+ if (mergeBaseHash === parentBranchNameSha && mergeBaseHash !== branchNameSha) {
558
+ logger.log(`'${parentBranchName}' is a direct ancestor of '${branchName}'. Finding the true branching point.`);
559
+ // The first child-only commit (oldest commit on branchName not on parent)
560
+ // — its parent commit IS the divergence point.
561
+ const firstCommitOnChild = (await executeWithLog(`git rev-list ^${parentBranchName} ${branchName} | tail -n 1`, {
562
+ execOptions,
563
+ logger,
564
+ })).stdout.trim();
565
+ if (firstCommitOnChild) {
566
+ const parentOfFirstCommit = (await executeWithLog(`git rev-parse ${firstCommitOnChild}^`, { execOptions, logger })).stdout.trim();
567
+ if (isDebugMode())
568
+ logger.log(`Branching point identified as: ${parentOfFirstCommit}`);
569
+ finalHash = parentOfFirstCommit;
570
+ }
571
+ else {
572
+ logger.log('Could not determine the first commit on the child branch. Using merge-base as fallback.');
573
+ }
574
+ }
575
+ // Step 6: hash -> ISO timestamp.
576
+ const timestamp = await getTimestampForHash(finalHash);
577
+ if (timestamp) {
578
+ logger.log(`git branching timestamp for parent '${parentBranchName}' successfully extracted: ${timestamp}`);
579
+ return timestamp;
224
580
  }
225
581
  }, args => {
226
582
  var _a;
@@ -261,6 +617,243 @@ async function parallelWithLimit(items, concurrency, fn) {
261
617
  }
262
618
  return results;
263
619
  }
620
+ /**
621
+ * Enumerate ancestor branches in the window MERGE_BASE..gitBranchName by
622
+ * walking the commits in that window and asking which branches contain each.
623
+ *
624
+ * Mirrors the reference shell algorithm in
625
+ * `.team-lead/scripts-byCommits.sh`. Returns a sorted, deduplicated list
626
+ * of branch names (no `refs/` prefix). Never throws — returns `[]` on git
627
+ * failure. Always includes `defaultBranch` and `gitBranchName`.
628
+ */
629
+ async function getBranchAncestryByCommits({ gitBranchName, defaultBranch, execOptions, logger = (0, logger_1.makeLogger)(), }) {
630
+ logger = logger.extend({ tags: [`get-branch-ancestry-by-commits-${utils.general.shortid()}`] });
631
+ if (isDebugMode()) {
632
+ logger.log(`[byCommits] Start: gitBranchName=${gitBranchName}, defaultBranch=${defaultBranch}`);
633
+ }
634
+ // Function-entry env gate.
635
+ const env = await (0, exports.checkGitEnvironment)({ execOptions, logger });
636
+ if (!env.ok) {
637
+ logger.log('[byCommits] git environment not ok, returning []');
638
+ return [];
639
+ }
640
+ try {
641
+ // Step 1: find MERGE_BASE(HEAD, defaultBranch). This anchors the
642
+ // "ancestry window" we care about — commits made on the current branch
643
+ // after this point are uniquely ours.
644
+ const mergeBaseResult = await executeWithLog(`git merge-base ${gitBranchName} ${defaultBranch}`, {
645
+ execOptions,
646
+ logger,
647
+ });
648
+ if (mergeBaseResult.stderr || !mergeBaseResult.stdout.trim()) {
649
+ // Default-on: an empty result without explanation is the worst kind of debugging.
650
+ logger.log(`[byCommits] ✗ merge-base ${gitBranchName} ${defaultBranch} failed (stderr="${mergeBaseResult.stderr}"), returning []`);
651
+ return [];
652
+ }
653
+ const mergeBase = mergeBaseResult.stdout.trim();
654
+ if (isDebugMode())
655
+ logger.log(`[byCommits] Merge-base resolved: ${mergeBase}`);
656
+ // Step 2: enumerate every commit in the window MERGE_BASE..HEAD.
657
+ const logResult = await executeWithLog(`git log --format=%H ${mergeBase}..${gitBranchName}`, { execOptions, logger });
658
+ if (logResult.stderr) {
659
+ logger.log(`[byCommits] ✗ git log ${mergeBase}..${gitBranchName} failed (stderr="${logResult.stderr}"), returning []`);
660
+ return [];
661
+ }
662
+ const commits = logResult.stdout.trim().split('\n').filter(Boolean);
663
+ if (isDebugMode())
664
+ logger.log(`[byCommits] Walking ${commits.length} commit(s) in window ${mergeBase}..${gitBranchName}`);
665
+ // Step 3: for each commit, ask which branches contain it. The union of
666
+ // those answers gives us every branch that could be a parent.
667
+ const candidates = new Set([defaultBranch, gitBranchName]);
668
+ const containResults = await parallelWithLimit(commits, 10, async (hash) => {
669
+ const r = await executeWithLog(`git branch -a --contains ${hash} --format='%(refname:short)'`, {
670
+ execOptions,
671
+ logger,
672
+ });
673
+ if (r.stderr)
674
+ return [];
675
+ return r.stdout
676
+ .split('\n')
677
+ .map(line => line.trim())
678
+ .filter(b => b && b !== 'HEAD');
679
+ });
680
+ // Verbose: full per-commit dump (can be hundreds of entries on long branches).
681
+ if (isDebugMode()) {
682
+ logger.log(`[byCommits] Branches containing commits in ${mergeBase}..${gitBranchName}:`, containResults);
683
+ }
684
+ for (const list of containResults)
685
+ for (const b of list)
686
+ candidates.add(b);
687
+ if (isDebugMode()) {
688
+ logger.log(`[byCommits] Unioned candidates before descendant filter (${candidates.size}): ${Array.from(candidates).join(', ')}`);
689
+ }
690
+ // Step 4: filter out descendant branches. A candidate that has
691
+ // gitBranchName as its ancestor is downstream of us (a feature branch
692
+ // built ON TOP of this one), not an upstream parent — exclude it.
693
+ const remoteName = await (0, exports.getPrimaryRemoteName)({ execOptions, logger });
694
+ let droppedSelf = 0;
695
+ let droppedDescendants = 0;
696
+ const finalCandidates = await parallelWithLimit(Array.from(candidates), 10, async (branch) => {
697
+ const normalizedBranch = branch.replace(new RegExp(`^${remoteName}/`), '');
698
+ if (normalizedBranch === gitBranchName) {
699
+ droppedSelf++;
700
+ return null; // Exclude the current branch itself
701
+ }
702
+ // `merge-base --is-ancestor` exits 0 when arg1 is an ancestor of arg2.
703
+ // So exit 0 here means "current branch IS an ancestor of candidate"
704
+ // → candidate is a descendant → drop it.
705
+ const isDescendantResult = await executeWithLog(`git merge-base --is-ancestor ${gitBranchName} ${normalizedBranch}`, {
706
+ execOptions,
707
+ logger,
708
+ });
709
+ if (isDescendantResult.code === 0) {
710
+ droppedDescendants++;
711
+ // Verbose: fires once per descendant candidate.
712
+ if (isDebugMode())
713
+ logger.log(`[byCommits] DROP descendant: ${normalizedBranch}`);
714
+ return null;
715
+ }
716
+ if (isDebugMode())
717
+ logger.log(`[byCommits] KEEP ancestor: ${normalizedBranch}`);
718
+ return normalizedBranch;
719
+ });
720
+ const finalCleanBranches = finalCandidates.filter((b) => b !== null);
721
+ const result = Array.from(new Set(finalCleanBranches)).sort();
722
+ if (isDebugMode()) {
723
+ logger.log(`[byCommits] Done. dropped(self=${droppedSelf}, descendants=${droppedDescendants}) → ${result.length} ancestor(s): ${result.join(', ')}`);
724
+ }
725
+ return result;
726
+ }
727
+ catch (err) {
728
+ logger.log('[byCommits] ✗ ERROR', err);
729
+ return [];
730
+ }
731
+ }
732
+ exports.getBranchAncestryByCommits = getBranchAncestryByCommits;
733
+ /**
734
+ * Enumerate ancestor branches by iterating every branch ref and applying
735
+ * Rule 1 (the candidate's merge-base with HEAD must not lie before the
736
+ * primary MERGE_BASE) and Rule 2 (HEAD must not be an ancestor of the
737
+ * candidate).
738
+ *
739
+ * Mirrors the reference shell algorithm in
740
+ * `.team-lead/scripts-byLocalBranches.sh`, with one deliberate extension:
741
+ * the shell script reads `refs/heads/` only, but in CI we typically have
742
+ * exactly one local branch checked out and dozens of remote refs. To make
743
+ * this method useful in CI we ALSO walk `refs/remotes/<remote>/` and strip
744
+ * the remote prefix. The function name is preserved for fidelity to the
745
+ * reference algorithm.
746
+ *
747
+ * Returns a sorted, deduplicated list of branch names. Never throws —
748
+ * returns `[]` on git failure. Always includes `defaultBranch` and
749
+ * `gitBranchName`.
750
+ */
751
+ async function getBranchAncestryByLocalBranches({ gitBranchName, defaultBranch, execOptions, logger = (0, logger_1.makeLogger)(), }) {
752
+ logger = logger.extend({ tags: [`get-branch-ancestry-by-local-branches-${utils.general.shortid()}`] });
753
+ if (isDebugMode()) {
754
+ logger.log(`[byLocalBranches] Start: gitBranchName=${gitBranchName}, defaultBranch=${defaultBranch}`);
755
+ }
756
+ // Function-entry env gate.
757
+ const env = await (0, exports.checkGitEnvironment)({ execOptions, logger });
758
+ if (!env.ok) {
759
+ logger.log('[byLocalBranches] git environment not ok, returning []');
760
+ return [];
761
+ }
762
+ // Per-rule counters for the final summary — invaluable when debugging
763
+ // "why did so many branches get filtered out?"
764
+ let droppedNoMb = 0;
765
+ let droppedRule1 = 0;
766
+ let droppedRule2 = 0;
767
+ let pinned = 0;
768
+ try {
769
+ // 1. Merge-base of CURRENT and DEFAULT. This is the "primary MERGE_BASE"
770
+ // used by Rule 1 below.
771
+ const mergeBaseResult = await executeWithLog(`git merge-base ${gitBranchName} ${defaultBranch}`, {
772
+ execOptions,
773
+ logger,
774
+ });
775
+ if (mergeBaseResult.stderr || !mergeBaseResult.stdout.trim()) {
776
+ // Default-on: an empty result without explanation is the worst kind of debugging.
777
+ logger.log(`[byLocalBranches] ✗ merge-base ${gitBranchName} ${defaultBranch} failed (stderr="${mergeBaseResult.stderr}"), returning []`);
778
+ return [];
779
+ }
780
+ const mergeBase = mergeBaseResult.stdout.trim();
781
+ if (isDebugMode())
782
+ logger.log(`[byLocalBranches] Primary merge-base: ${mergeBase}`);
783
+ // 2. Enumerate candidate branches from BOTH local AND remote refs.
784
+ const remoteName = await (0, exports.getPrimaryRemoteName)({ execOptions, logger });
785
+ const refsResult = await executeWithLog(`git for-each-ref --format='%(refname:short)' refs/heads/ refs/remotes/${remoteName}/`, { execOptions, logger });
786
+ if (refsResult.stderr) {
787
+ logger.log(`[byLocalBranches] ✗ for-each-ref failed (stderr="${refsResult.stderr}"), returning []`);
788
+ return [];
789
+ }
790
+ const candidateBranches = new Set([defaultBranch, gitBranchName]);
791
+ for (const raw of refsResult.stdout.split('\n')) {
792
+ const trimmed = raw.trim();
793
+ if (!trimmed)
794
+ continue;
795
+ const normalized = trimmed.replace(new RegExp(`^${remoteName}/`), '');
796
+ if (normalized && normalized !== 'HEAD')
797
+ candidateBranches.add(normalized);
798
+ }
799
+ if (isDebugMode()) {
800
+ logger.log(`[byLocalBranches] Enumerated ${candidateBranches.size} candidate(s): ${Array.from(candidateBranches).join(', ')}`);
801
+ }
802
+ // 3. Apply Rule 1 and Rule 2 for every non-pinned candidate.
803
+ // Pinned = defaultBranch and gitBranchName, which always pass through.
804
+ const results = await parallelWithLimit(Array.from(candidateBranches), 10, async (branch) => {
805
+ if (branch === defaultBranch || branch === gitBranchName) {
806
+ pinned++;
807
+ if (isDebugMode())
808
+ logger.log(`[byLocalBranches] KEEP pinned: ${branch}`);
809
+ return branch;
810
+ }
811
+ // Rule 1: skip branches whose merge-base with CURRENT is at or before
812
+ // MERGE_BASE (they diverged before our window — they're not in
813
+ // the current branch's ancestry, they're parallel history).
814
+ const branchMbR = await executeWithLog(`git merge-base ${branch} ${gitBranchName}`, { execOptions, logger });
815
+ if (branchMbR.stderr || !branchMbR.stdout.trim()) {
816
+ droppedNoMb++;
817
+ if (isDebugMode()) {
818
+ logger.log(`[byLocalBranches] DROP no-merge-base: ${branch} (stderr="${branchMbR.stderr}")`);
819
+ }
820
+ return null;
821
+ }
822
+ const branchMb = branchMbR.stdout.trim().split('\n')[0];
823
+ const r1 = await executeWithLog(`git merge-base --is-ancestor ${branchMb} ${mergeBase}`, { execOptions, logger });
824
+ if (r1.code === 0) {
825
+ droppedRule1++;
826
+ if (isDebugMode()) {
827
+ logger.log(`[byLocalBranches] DROP Rule 1 (diverged before window): ${branch} — its mb=${branchMb} is ancestor of primary ${mergeBase}`);
828
+ }
829
+ return null;
830
+ }
831
+ // Rule 2: skip branches strictly ahead of CURRENT (HEAD is an ancestor
832
+ // of them — they're descendants, not parents).
833
+ const r2 = await executeWithLog(`git merge-base --is-ancestor ${gitBranchName} ${branch}`, { execOptions, logger });
834
+ if (r2.code === 0) {
835
+ droppedRule2++;
836
+ if (isDebugMode()) {
837
+ logger.log(`[byLocalBranches] DROP Rule 2 (descendant): ${branch} — ${gitBranchName} is its ancestor`);
838
+ }
839
+ return null;
840
+ }
841
+ if (isDebugMode())
842
+ logger.log(`[byLocalBranches] KEEP ancestor: ${branch} (mb=${branchMb})`);
843
+ return branch;
844
+ });
845
+ const result = Array.from(new Set(results.filter((b) => b !== null))).sort();
846
+ if (isDebugMode()) {
847
+ logger.log(`[byLocalBranches] Done. pinned=${pinned}, dropped(no-mb=${droppedNoMb}, rule1=${droppedRule1}, rule2=${droppedRule2}) → ${result.length} ancestor(s): ${result.join(', ')}`);
848
+ }
849
+ return result;
850
+ }
851
+ catch (err) {
852
+ logger.log('[byLocalBranches] ✗ ERROR', err);
853
+ return [];
854
+ }
855
+ }
856
+ exports.getBranchAncestryByLocalBranches = getBranchAncestryByLocalBranches;
264
857
  /**
265
858
  * Get all remote branches from the primary remote (cached for performance)
266
859
  * Uses git ls-remote which is fast and doesn't require fetching data
@@ -272,6 +865,13 @@ const getAllRemoteBranches = utils.general.cachify(async function ({ execOptions
272
865
  logger.log('[getAllRemoteBranches] Starting git ls-remote to fetch all remote branches...');
273
866
  logger.log('[getAllRemoteBranches] execOptions.cwd:', (execOptions === null || execOptions === void 0 ? void 0 : execOptions.cwd) || 'undefined');
274
867
  }
868
+ // Short-circuit when the remote isn't reachable — no point retrying
869
+ // ls-remote when we know it will fail.
870
+ const env = await (0, exports.checkGitEnvironment)({ execOptions, logger });
871
+ if (!env.remoteAccessible) {
872
+ logger.log('[getAllRemoteBranches] Skipping ls-remote (remote not accessible), returning empty set');
873
+ return new Set();
874
+ }
275
875
  try {
276
876
  // Get the primary remote name (cached)
277
877
  const remoteName = await (0, exports.getPrimaryRemoteName)({ execOptions, logger });
@@ -332,9 +932,15 @@ const getAllRemoteBranches = utils.general.cachify(async function ({ execOptions
332
932
  // Custom cache key generator - only cache by cwd, not by logger instance
333
933
  args => { var _a; return ({ cwd: (_a = args[0].execOptions) === null || _a === void 0 ? void 0 : _a.cwd }); }, 5 * 60 * 1000);
334
934
  /**
335
- * Determine fetch strategy and execute appropriate fetch operation
935
+ * Determine fetch strategy and execute appropriate fetch operation.
936
+ * No-op when the remote isn't reachable.
336
937
  */
337
938
  async function executeFetchStrategy(isShallow, execOptions, logger) {
939
+ const env = await (0, exports.checkGitEnvironment)({ execOptions, logger });
940
+ if (!env.remoteAccessible) {
941
+ logger.log('executeFetchStrategy: skipping fetch (remote not accessible)');
942
+ return;
943
+ }
338
944
  const remoteName = await (0, exports.getPrimaryRemoteName)({ execOptions, logger });
339
945
  if (isShallow) {
340
946
  // Shallow clone needs full unshallow for initial topology discovery
@@ -366,6 +972,13 @@ async function executeFetchStrategy(isShallow, execOptions, logger) {
366
972
  * @param logger - Logger instance
367
973
  */
368
974
  async function ensureRemoteBranchesAvailable(branchName, isShallow, execOptions, logger) {
975
+ // Skip entirely when the remote is unreachable — neither the config rewrite
976
+ // nor the downstream fetches will do anything useful.
977
+ const env = await (0, exports.checkGitEnvironment)({ execOptions, logger });
978
+ if (!env.remoteAccessible) {
979
+ logger.log('ensureRemoteBranchesAvailable: skipping (remote not accessible)');
980
+ return;
981
+ }
369
982
  try {
370
983
  // Get the primary remote name (cached)
371
984
  const remoteName = await (0, exports.getPrimaryRemoteName)({ execOptions, logger });
@@ -396,12 +1009,17 @@ async function ensureRemoteBranchesAvailable(branchName, isShallow, execOptions,
396
1009
  * Extracts a list of ancestor branches for the given branch, ordered by most recent commit timestamp.
397
1010
  *
398
1011
  * Algorithm:
399
- * 1. Check if shallow clone and should skip (via APPLITOOLS_SKIP_BRANCH_LOOKUP_IN_SHALLOW_CLONE)
400
- * 2. Ensure remote branches are available (fetch if needed for single-branch/shallow clones)
401
- * 3. Use git topology to discover ancestor branches efficiently (O(log N) via --first-parent --simplify-by-decoration)
402
- * 4. For each discovered branch, extract the branching timestamp (latest viable commit where branch existed)
403
- * 5. Sort results by timestamp descending (most recent first)
404
- * 6. Return cached results (5-minute TTL) to avoid redundant git operations
1012
+ * 1. Check if shallow clone and should skip (via APPLITOOLS_SKIP_BRANCH_LOOKUP_IN_SHALLOW_CLONE).
1013
+ * 2. Ensure remote branches are available (fetch if needed for single-branch/shallow clones).
1014
+ * 3. Detect the repo's actual default branch via `extractDefaultBranch()` (handles non-standard
1015
+ * defaults like JFrog's `JFROG/.../...-rc`).
1016
+ * 4. Discover ancestor branches using the strategy selected by
1017
+ * `APPLITOOLS_BRANCH_ANCESTRY_STRATEGY` (`commits` (default) or `local`):
1018
+ * - `getBranchAncestryByCommits` walks the MERGE_BASE..HEAD commit window;
1019
+ * - `getBranchAncestryByLocalBranches` iterates `refs/heads/` + `refs/remotes/<remote>/`.
1020
+ * 5. For each discovered branch, extract the branching timestamp.
1021
+ * 6. Sort results by timestamp descending (most recent first), tie-break by branch name.
1022
+ * 7. Root-branch safety net: if nothing survived, fall back to the detected default branch.
405
1023
  *
406
1024
  * @param gitBranchName - The branch to analyze
407
1025
  * @param execOptions - Git execution options (e.g., cwd)
@@ -411,59 +1029,69 @@ async function ensureRemoteBranchesAvailable(branchName, isShallow, execOptions,
411
1029
  exports.extractBranchLookupFallbackList = utils.general.cachify(async function ({ gitBranchName, execOptions, logger = (0, logger_1.makeLogger)(), enableShallowClone = true, }) {
412
1030
  const functionStartTime = Date.now();
413
1031
  logger = logger.extend({ tags: [`extract-branch-fallback-list-${utils.general.shortid()}`] });
414
- logger.log(`[PERF] extractBranchLookupFallbackList started for branch: ${gitBranchName}`);
1032
+ if (isDebugMode())
1033
+ logger.log(`[PERF] extractBranchLookupFallbackList started for branch: ${gitBranchName}`);
1034
+ // Function-entry env gate. Everything downstream shells out to git.
1035
+ const env = await (0, exports.checkGitEnvironment)({ execOptions, logger });
1036
+ if (!env.ok) {
1037
+ logger.log('extractBranchLookupFallbackList: git environment not ok, skipping');
1038
+ return undefined;
1039
+ }
415
1040
  try {
416
- // 1. Check if this is a shallow clone and if we should skip based on env var
417
- // add this to control of the user
1041
+ // 1. Shallow-clone gate: APPLITOOLS_SKIP_BRANCH_LOOKUP_IN_SHALLOW_CLONE
1042
+ // lets users opt out of expensive unshallow-fetches in CI.
418
1043
  const shallowCheckStartTime = Date.now();
419
1044
  const shallowCheckResult = await executeWithLog('git rev-parse --is-shallow-repository', {
420
1045
  execOptions,
421
1046
  logger,
422
1047
  });
423
- logger.log(`[PERF] Shallow check took ${Date.now() - shallowCheckStartTime}ms`);
1048
+ if (isDebugMode())
1049
+ logger.log(`[PERF] Shallow check took ${Date.now() - shallowCheckStartTime}ms`);
424
1050
  const isShallow = shallowCheckResult.stdout.trim() === 'true';
425
1051
  if (!enableShallowClone && isShallow) {
426
1052
  logger.log('Shallow clone detected and APPLITOOLS_SKIP_BRANCH_LOOKUP_IN_SHALLOW_CLONE is enabled, skipping branch lookup');
427
1053
  return undefined;
428
1054
  }
429
- // 2. Ensure remote branches are available BEFORE topology discovery
430
- // This is necessary for single-branch clones and shallow clones where branches don't exist locally
1055
+ // 2. Ensure topology is fetchable: single-branch / shallow clones don't
1056
+ // have the other branches' refs locally fetch them so subsequent
1057
+ // merge-base calls have something to work with.
431
1058
  const ensureRemoteStartTime = Date.now();
432
1059
  await ensureRemoteBranchesAvailable(gitBranchName, isShallow, execOptions, logger);
433
- logger.log(`[PERF] ensureRemoteBranchesAvailable took ${Date.now() - ensureRemoteStartTime}ms`);
434
- // 3. OPTIMIZATION: Use git topology to discover only ancestor branches (O(log N) instead of O(N))
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)
1060
+ if (isDebugMode())
1061
+ logger.log(`[PERF] ensureRemoteBranchesAvailable took ${Date.now() - ensureRemoteStartTime}ms`);
1062
+ // Cached lookup of the primary remote (typically `origin`).
440
1063
  const remoteName = await (0, exports.getPrimaryRemoteName)({ execOptions, logger });
441
- const logResult = await executeWithLog(`git log --first-parent --simplify-by-decoration --format="%D" ${gitBranchName}`, { execOptions, logger });
442
- const foundBranches = new Set();
443
- if (!logResult.stderr && logResult.stdout.trim()) {
444
- const rawLines = logResult.stdout.split('\n');
445
- for (const line of rawLines) {
446
- // Parse refs like: "HEAD -> feat, origin/feat, tag: v1, origin/main"
447
- const refs = line.split(',').map(r => r.trim());
448
- for (const ref of refs) {
449
- if (ref.includes('tag:'))
450
- continue; // Ignore tags
451
- const cleanRef = ref.replace('HEAD -> ', '').replace(new RegExp(`^${remoteName}/`), ''); // Normalize
452
- if (cleanRef && cleanRef !== gitBranchName && cleanRef !== 'HEAD')
453
- foundBranches.add(cleanRef);
454
- }
455
- }
1064
+ // 3. Detect the repo's actual default branch (handles JFrog-style non-standard names).
1065
+ const defaultBranch = await (0, exports.extractDefaultBranch)({ execOptions, logger });
1066
+ logger.log(`Default branch detected: ${defaultBranch}`);
1067
+ // 4. Discover ancestor branches using the chosen strategy.
1068
+ // APPLITOOLS_BRANCH_ANCESTRY_STRATEGY=commits (default) | local
1069
+ const ancestryStrategy = (process.env.APPLITOOLS_BRANCH_ANCESTRY_STRATEGY || 'commits').trim().toLowerCase();
1070
+ const ancestryDriver = ancestryStrategy === 'local' ? getBranchAncestryByLocalBranches : getBranchAncestryByCommits;
1071
+ const ancestryStartTime = Date.now();
1072
+ logger.log(`Discovering ancestor branches via "${ancestryStrategy}" strategy for ${gitBranchName} (default=${defaultBranch})...`);
1073
+ const ancestry = await ancestryDriver({ gitBranchName, defaultBranch, execOptions, logger });
1074
+ if (isDebugMode())
1075
+ logger.log(`[PERF] Ancestry discovery took ${Date.now() - ancestryStartTime}ms`);
1076
+ if (isDebugMode()) {
1077
+ logger.log(`Ancestry discovery found ${ancestry.length} candidate branches: ${ancestry.join(', ')}`);
456
1078
  }
457
- let allBranches = Array.from(foundBranches);
458
- logger.log(`[PERF] Topology discovery took ${Date.now() - topologyStartTime}ms`);
1079
+ else {
1080
+ logger.log(`Ancestry discovery found ${ancestry.length} candidate branches`);
1081
+ }
1082
+ // The helpers return defaultBranch + gitBranchName + ancestors; strip current branch
1083
+ // for parity with the rest of the function (caller adds it back via timestamp logic).
1084
+ let allBranches = ancestry.filter(b => b !== gitBranchName);
459
1085
  if (isDebugMode()) {
460
- logger.log(`Topology discovered ${allBranches.length} potential ancestor branches: ${allBranches.join(', ')}`);
1086
+ logger.log(`Ancestry discovered ${allBranches.length} potential ancestor branches: ${allBranches.join(', ')}`);
461
1087
  }
462
1088
  else {
463
- logger.log(`Topology discovered ${allBranches.length} potential ancestor branches`);
1089
+ logger.log(`Ancestry discovered ${allBranches.length} potential ancestor branches`);
464
1090
  }
465
- // 3. Filter out branches that don't exist on remote (if we can determine remote branches)
466
- // This prevents unnecessary ancestor checks and fetch attempts for missing branches
1091
+ // 4b. Drop branches that the ancestry helper found locally but that
1092
+ // don't exist on the remote fetching/merge-base'ing against them
1093
+ // would just waste time and surface confusing errors. Cheap because
1094
+ // `getAllRemoteBranches` is cached.
467
1095
  const remoteBranchesStartTime = Date.now();
468
1096
  if (isDebugMode()) {
469
1097
  logger.log('[Remote Filtering] ========================================');
@@ -516,51 +1144,15 @@ exports.extractBranchLookupFallbackList = utils.general.cachify(async function (
516
1144
  logger.log('[Remote Filtering] Continuing with all discovered branches');
517
1145
  }
518
1146
  }
519
- logger.log(`[Remote Filtering] [PERF] Remote branch filtering took ${Date.now() - remoteBranchesStartTime}ms`);
520
1147
  if (isDebugMode()) {
1148
+ logger.log(`[Remote Filtering] [PERF] Remote branch filtering took ${Date.now() - remoteBranchesStartTime}ms`);
521
1149
  logger.log('[Remote Filtering] ========================================');
522
1150
  }
523
- // 4. Filter out sibling branches - keep only TRUE ancestors
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
1151
+ // 5. For each surviving ancestor, compute the branching timestamp in
1152
+ // parallel. The ancestry helper has already pruned siblings and
1153
+ // descendants, so every entry here should be a real ancestor.
562
1154
  const timestampStartTime = Date.now();
563
- const branchesWithTimestamps = await Promise.all(trueAncestors.map(async (ancestorBranch) => {
1155
+ const branchesWithTimestamps = await Promise.all(allBranches.map(async (ancestorBranch) => {
564
1156
  const timestamp = await (0, exports.extractBranchingTimestamp)({
565
1157
  branchName: gitBranchName,
566
1158
  parentBranchName: ancestorBranch,
@@ -569,7 +1161,8 @@ exports.extractBranchLookupFallbackList = utils.general.cachify(async function (
569
1161
  });
570
1162
  return timestamp ? { branchName: ancestorBranch, latestViableTimestamp: timestamp } : null;
571
1163
  }));
572
- logger.log(`[PERF] Timestamp extraction took ${Date.now() - timestampStartTime}ms`);
1164
+ if (isDebugMode())
1165
+ logger.log(`[PERF] Timestamp extraction took ${Date.now() - timestampStartTime}ms`);
573
1166
  // 6. Filter out null results and sort by timestamp (most recent first), then by branch name
574
1167
  const validBranches = branchesWithTimestamps
575
1168
  .filter((item) => item !== null)
@@ -613,17 +1206,15 @@ exports.extractBranchLookupFallbackList = utils.general.cachify(async function (
613
1206
  .map(line => line.trim())
614
1207
  .filter(line => line && !line.includes('->') && !line.includes('HEAD'))
615
1208
  .map(line => line.replace(new RegExp(`^${remoteName}/`), '').replace(/^\* /, '')); // Remove remote prefix and '* ' prefix
616
- // Try common root branch names first: main, master, develop
617
- const commonRootNames = ['main', 'master', 'develop', 'trunk'];
1209
+ // Prefer the programmatically-detected default branch (handles non-standard names
1210
+ // like JFrog's `JFROG/artifactory-mfe/preRelease/jfmfe-1.995.x-rc`) over a hardcoded list.
618
1211
  let rootBranch = null;
619
- for (const commonName of commonRootNames) {
620
- if (branchesContainingRoot.includes(commonName)) {
621
- rootBranch = commonName;
622
- logger.log(`Found root branch using common name: ${rootBranch}`);
623
- break;
624
- }
1212
+ if (branchesContainingRoot.includes(defaultBranch)) {
1213
+ rootBranch = defaultBranch;
1214
+ logger.log(`Found root branch via detected defaultBranch: ${rootBranch}`);
625
1215
  }
626
- // If no common name found, use the oldest branch (most likely the root)
1216
+ // If the detected default isn't among the root-containing branches,
1217
+ // fall back to the oldest branch (most likely the historical root).
627
1218
  if (!rootBranch && branchesContainingRoot.length > 0) {
628
1219
  // Find the branch with the oldest creation timestamp among those containing root commit
629
1220
  const branchTimestamps = await Promise.all(branchesContainingRoot.slice(0, 10).map(async (branch) => {
@@ -696,8 +1287,15 @@ exports.extractBranchLookupFallbackList = utils.general.cachify(async function (
696
1287
  catch (err) {
697
1288
  logger.log('Failed to detect and add root branch, continuing without it', err);
698
1289
  }
699
- logger.log(`[PERF] extractBranchLookupFallbackList completed in ${Date.now() - functionStartTime}ms total`);
700
- logger.log('Successfully extracted branch lookup fallback list', JSON.stringify(validBranches));
1290
+ if (isDebugMode()) {
1291
+ logger.log(`[PERF] extractBranchLookupFallbackList completed in ${Date.now() - functionStartTime}ms total`);
1292
+ }
1293
+ if (isDebugMode()) {
1294
+ logger.log(`Successfully extracted branch lookup fallback list (${validBranches.length} entries): ${JSON.stringify(validBranches)}`);
1295
+ }
1296
+ else {
1297
+ logger.log(`Successfully extracted branch lookup fallback list (${validBranches.length} entries)`);
1298
+ }
701
1299
  return validBranches.length > 0 ? validBranches : undefined;
702
1300
  }
703
1301
  catch (err) {