@applitools/core 4.67.0 → 4.67.1

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 CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## [4.67.1](https://github.com/Applitools-Dev/sdk/compare/js/core@4.67.0...js/core@4.67.1) (2026-07-29)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * capture CSS Font Loading API fonts in DOM snapshot | FLD-4754 ([#4024](https://github.com/Applitools-Dev/sdk/issues/4024)) ([3df71ed](https://github.com/Applitools-Dev/sdk/commit/3df71ede021072c7ad249d548465b72776672282))
9
+ * include related branches in git branch-lookup fallback list | AD-15279 ([#4015](https://github.com/Applitools-Dev/sdk/issues/4015)) ([bceb7e1](https://github.com/Applitools-Dev/sdk/commit/bceb7e1bbb98a1138105629eec446f94e328ae30))
10
+
11
+
12
+ ### Dependencies
13
+
14
+ * @applitools/dom-snapshot bumped to 4.17.7
15
+ #### Bug Fixes
16
+
17
+ * capture CSS Font Loading API fonts in DOM snapshot | FLD-4754 ([#4024](https://github.com/Applitools-Dev/sdk/issues/4024)) ([3df71ed](https://github.com/Applitools-Dev/sdk/commit/3df71ede021072c7ad249d548465b72776672282))
18
+
3
19
  ## [4.67.0](https://github.com/Applitools-Dev/sdk/compare/js/core@4.66.2...js/core@4.67.0) (2026-07-23)
4
20
 
5
21
 
package/dist/open-eyes.js CHANGED
@@ -23,7 +23,7 @@ var __importStar = (this && this.__importStar) || function (mod) {
23
23
  return result;
24
24
  };
25
25
  Object.defineProperty(exports, "__esModule", { value: true });
26
- exports.handleConcurrency = exports.getLatestCommitInfoFromEnvVars = exports.makeOpenEyes = void 0;
26
+ exports.handleConcurrency = exports.getLatestCommitInfoFromEnvVars = exports.makeOpenEyes = exports.sanitizeFinding = void 0;
27
27
  const driver_1 = require("@applitools/driver");
28
28
  const core_1 = require("./classic/core");
29
29
  const core_2 = require("./ufg/core");
@@ -38,6 +38,24 @@ const utils = __importStar(require("@applitools/utils"));
38
38
  const core_base_1 = require("@applitools/core-base");
39
39
  const extract_git_info_1 = require("./utils/extract-git-info");
40
40
  const types_1 = require("./types");
41
+ const reportedScmPreconditionFindings = new Map();
42
+ const scmPreconditionKey = (cwd, gitBranchName) => `${cwd} ${gitBranchName !== null && gitBranchName !== void 0 ? gitBranchName : ''}`;
43
+ const findingsHash = (findings) => findings
44
+ .map(finding => finding.code)
45
+ .sort()
46
+ .join(',');
47
+ const CREDENTIALS_IN_URL = /([a-z][a-z0-9+.-]*:\/\/)[^/@\s]*@/gi;
48
+ const MAX_FINDING_DETAIL_LENGTH = 512;
49
+ // SECURITY: `detail` is `env.reason` / raw git stderr, so it can embed a remote URL with `user:token@` userinfo.
50
+ function sanitizeFinding(finding) {
51
+ // Pre-slice: CREDENTIALS_IN_URL backtracks quadratically on remote-controlled git stderr (≤1 MB via exec maxBuffer).
52
+ let detail = finding.detail.slice(0, 4 * MAX_FINDING_DETAIL_LENGTH).replace(CREDENTIALS_IN_URL, '$1***@');
53
+ if (detail.length > MAX_FINDING_DETAIL_LENGTH) {
54
+ detail = `${detail.slice(0, MAX_FINDING_DETAIL_LENGTH - 1)}…`;
55
+ }
56
+ return { code: finding.code, detail };
57
+ }
58
+ exports.sanitizeFinding = sanitizeFinding;
41
59
  function makeOpenEyes({ type: defaultType = 'classic', clients, batch, removeDuplicateTests, core, cores, spec, environment, logger: mainLogger, asyncCache, concurrency, useServerConcurrency = false, cwd = process.cwd(), }) {
42
60
  let shouldPrintConcurrencyWarning = true;
43
61
  return async function openEyes({ type = defaultType, settings: openSettings, config, target, logger = mainLogger, }) {
@@ -204,8 +222,9 @@ function makeOpenEyes({ type: defaultType = 'classic', clients, batch, removeDup
204
222
  async function handleBranchFallbackList(account, settings, logger) {
205
223
  var _a, _b;
206
224
  const scm = getScmSettings(account.scmSettings);
225
+ const integrationActive = !settings.ignoreGitBranching && scm.lookupFallbackList;
207
226
  // Extract gitBranchName (the actual git branch name)
208
- if (!settings.ignoreGitBranching && scm.lookupFallbackList) {
227
+ if (integrationActive) {
209
228
  logger.log(`Extracting git branch name, ${cwd}`);
210
229
  settings.gitBranchName = await (0, extract_git_info_1.extractGitBranch)({
211
230
  execOptions: { cwd },
@@ -219,17 +238,63 @@ function makeOpenEyes({ type: defaultType = 'classic', clients, batch, removeDup
219
238
  else {
220
239
  logger.log(`Skipping extraction of git branch name as ignoreGitBranching is set to true`);
221
240
  }
222
- if (settings.gitBranchName && !settings.ignoreGitBranching && scm.lookupFallbackList) {
241
+ // No branch name means the extractor never runs, so probe the env here instead.
242
+ if (integrationActive && !settings.gitBranchName) {
243
+ const env = await (0, extract_git_info_1.checkGitEnvironment)({ execOptions: { cwd }, logger });
244
+ reportScmPreconditionFailure({
245
+ key: scmPreconditionKey(cwd),
246
+ account,
247
+ settings,
248
+ findings: (0, extract_git_info_1.gitEnvironmentFindings)(env),
249
+ logger,
250
+ });
251
+ }
252
+ if (settings.gitBranchName && integrationActive) {
223
253
  logger.log(`Extracting branch lookup fallback list for branch "${settings.gitBranchName}, ${cwd}"`);
224
- // add write to coralogix before and after
225
- settings.branchLookupFallbackList = await (0, extract_git_info_1.extractBranchLookupFallbackList)({
254
+ const outcome = await extract_git_info_1.extractBranchLookupFallbackList.withDiagnostics({
226
255
  gitBranchName: `${settings.gitBranchName}`,
227
256
  enableShallowClone: (_b = scm.enableShallowClone) !== null && _b !== void 0 ? _b : false,
228
257
  execOptions: { cwd },
229
258
  logger,
230
259
  });
260
+ settings.branchLookupFallbackList = outcome.list;
261
+ if (outcome.status === 'precondition-failure') {
262
+ reportScmPreconditionFailure({
263
+ key: scmPreconditionKey(cwd, settings.gitBranchName),
264
+ account,
265
+ settings,
266
+ findings: outcome.diagnostics.findings,
267
+ defaultBranch: outcome.diagnostics.defaultBranch,
268
+ logger,
269
+ });
270
+ }
231
271
  }
232
272
  }
273
+ function reportScmPreconditionFailure({ key, account, settings, findings, defaultBranch, logger, }) {
274
+ const hash = findingsHash(findings);
275
+ if (reportedScmPreconditionFindings.get(key) === hash)
276
+ return;
277
+ reportedScmPreconditionFindings.set(key, hash);
278
+ emitScmPreconditionFailureEvent({ account, settings, findings, defaultBranch, logger });
279
+ }
280
+ function emitScmPreconditionFailureEvent({ account, settings, findings, defaultBranch, logger, }) {
281
+ var _a;
282
+ void core.logEvent({
283
+ settings: {
284
+ ...account.eyesServer,
285
+ level: 'Warn',
286
+ event: {
287
+ type: 'scmBranchLookupPreconditionFailure',
288
+ gitBranchName: settings.gitBranchName,
289
+ defaultBranch,
290
+ findings: findings.map(sanitizeFinding),
291
+ batchId: (_a = settings.batch) === null || _a === void 0 ? void 0 : _a.id,
292
+ userTestId: settings.userTestId,
293
+ },
294
+ },
295
+ logger,
296
+ });
297
+ }
233
298
  }
234
299
  exports.makeOpenEyes = makeOpenEyes;
235
300
  function refineMissingApiKeyException(error, sdk) {
@@ -26,7 +26,7 @@ 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.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;
29
+ exports.extractBranchLookupFallbackList = exports.getBranchAncestryByLocalBranches = exports.getBranchAncestryByCommits = exports.isISODate = exports.extractBranchingTimestamp = exports.extractBuildIdFromCI = exports.extractCIBranchName = exports.extractGitRepo = exports.extractGitBranch = exports.extractLatestCommitInfo = exports.extractDefaultBranch = exports.gitEnvironmentFindings = 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"));
@@ -211,6 +211,17 @@ exports.checkGitEnvironment = utils.general.cachify(async function ({ execOption
211
211
  };
212
212
  }
213
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
+ /** The finding `code`s are a telemetry wire contract — Coralogix dashboards match on them; do not rename. */
215
+ function gitEnvironmentFindings(env) {
216
+ if (!env.gitInstalled)
217
+ return [{ code: 'git-not-installed', detail: env.reason }];
218
+ if (!env.insideRepo)
219
+ return [{ code: 'not-a-work-tree', detail: env.reason }];
220
+ if (!env.remoteAccessible)
221
+ return [{ code: 'remote-unreachable', detail: env.reason }];
222
+ return [];
223
+ }
224
+ exports.gitEnvironmentFindings = gitEnvironmentFindings;
214
225
  /**
215
226
  * Grep-friendly env banner. The `APPLITOOLS GIT ENVIRONMENT CHECK` prefix is
216
227
  * unique to this codepath, so support can find the full verdict in one line.
@@ -679,26 +690,28 @@ async function parallelWithLimit(items, concurrency, fn) {
679
690
  }
680
691
  return results;
681
692
  }
693
+ // Ranks must stay distinct: sharing one drops the tie through to the alphabetical compare.
694
+ const RELATIONSHIP_RANK = {
695
+ lineage: 0,
696
+ 'fork-point': 1,
697
+ 'merge-absorbed': 2,
698
+ 'equal-tip': 3,
699
+ base: 4,
700
+ unknown: 5,
701
+ };
702
+ function relationshipRank(relationship) {
703
+ var _a;
704
+ return (_a = RELATIONSHIP_RANK[relationship]) !== null && _a !== void 0 ? _a : RELATIONSHIP_RANK.unknown;
705
+ }
706
+ // NaN would make the comparator inconsistent and the whole sort undefined, so sink the entry instead.
707
+ function epochOrOldest(timestamp) {
708
+ const epoch = new Date(timestamp).getTime();
709
+ return Number.isNaN(epoch) ? -Number.MAX_VALUE : epoch;
710
+ }
682
711
  /**
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.
712
+ * Enumerate RELATED (parent-shaped) branches in MERGE_BASE..gitBranchName,
713
+ * classified by keep-class. Returns a sorted, deduped list (no `refs/` prefix).
714
+ * Never throws — `[]` on git failure.
702
715
  */
703
716
  async function getBranchAncestryByCommits({ gitBranchName, defaultBranch, execOptions, logger = (0, logger_1.makeLogger)(), }) {
704
717
  logger = logger.extend({ tags: [`get-branch-ancestry-by-commits-${utils.general.shortid()}`] });
@@ -736,6 +749,24 @@ async function getBranchAncestryByCommits({ gitBranchName, defaultBranch, execOp
736
749
  const mergeBase = mergeBaseResult.stdout.trim();
737
750
  if (isDebugMode())
738
751
  logger.log(`[byCommits] Merge-base resolved: ${mergeBase}`);
752
+ // Step 1b: HEAD's first-parent chain (floor, HEAD], plus every criss-cross floor base.
753
+ // Stays null if listing fails, which makes step 4's strict-lineage filter skip (KEEP).
754
+ let fpChain = null;
755
+ const fpChainResult = await executeWithLog((0, shell_quote_1.quote)(['git', 'rev-list', '--first-parent', gitBranchName, `^${mergeBase}`]), { execOptions, logger });
756
+ if (fpChainResult.code === 0) {
757
+ fpChain = new Set(fpChainResult.stdout
758
+ .split('\n')
759
+ .map(line => line.trim())
760
+ .filter(Boolean));
761
+ const floorBases = await runGitContainsWithRetry((0, shell_quote_1.quote)(['git', 'merge-base', '--all', gitBranchName, defaultBranch]), { execOptions, logger });
762
+ for (const base of floorBases
763
+ .split('\n')
764
+ .map(line => line.trim())
765
+ .filter(Boolean)) {
766
+ fpChain.add(base);
767
+ }
768
+ fpChain.add(mergeBase);
769
+ }
739
770
  // Step 2: enumerate every commit in the window MERGE_BASE..HEAD.
740
771
  const logResult = await executeWithLog((0, shell_quote_1.quote)(['git', 'log', '--format=%H', `${mergeBase}..${gitBranchName}`]), {
741
772
  execOptions,
@@ -780,30 +811,14 @@ async function getBranchAncestryByCommits({ gitBranchName, defaultBranch, execOp
780
811
  if (isDebugMode()) {
781
812
  logger.log(`[byCommits] Unioned candidates before descendant filter (${candidates.size}): ${Array.from(candidates).join(', ')}`);
782
813
  }
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.
814
+ // Step 4: per-candidate keep/drop classifier (`mergeBase` is the floor).
815
+ // Error policy throughout: transient git error → KEEP. A false-drop loses a baseline forever.
803
816
  const remoteName = await (0, exports.getPrimaryRemoteName)({ execOptions, logger });
804
817
  let droppedSelf = 0;
805
818
  let droppedDescendants = 0;
806
819
  let droppedCousins = 0;
820
+ let droppedAbsorbedSiblings = 0;
821
+ let droppedDiverged = 0;
807
822
  const finalCandidates = await parallelWithLimit(Array.from(candidates), 10, async (branch) => {
808
823
  // `branch` may be local (`staging`) or remote-tracking (`origin/staging`).
809
824
  // Use the as-is form for git ops (git resolves both); strip only for the
@@ -812,23 +827,53 @@ async function getBranchAncestryByCommits({ gitBranchName, defaultBranch, execOp
812
827
  const normalizedBranch = branch.replace(new RegExp(`^${remoteName}/`), '');
813
828
  if (normalizedBranch === gitBranchName) {
814
829
  droppedSelf++;
815
- return null; // self
830
+ if (isDebugMode()) {
831
+ logger.log(`[byCommits] DROP self: ${normalizedBranch} — it is the branch under evaluation, not an ancestor`);
832
+ }
833
+ return null;
816
834
  }
817
835
  // Default branch always kept (see (c)-by-construction note above). Name
818
836
  // compare rather than a first-parent check: the default branch is the base.
819
837
  if (normalizedBranch === defaultBranch) {
820
838
  if (isDebugMode())
821
839
  logger.log(`[byCommits] KEEP base (default branch): ${normalizedBranch}`);
822
- return normalizedBranch;
840
+ return { name: normalizedBranch, relationship: 'base' };
823
841
  }
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).
842
+ // (b) is HEAD an ancestor of the candidate? Clean-false/error → (c).
827
843
  const isDescendant = await runGitPredicateWithRetry((0, shell_quote_1.quote)(['git', 'merge-base', '--is-ancestor', gitBranchName, branch]), { execOptions, logger });
828
844
  if (isDescendant === true) {
845
+ const headShaResult = await executeWithLog((0, shell_quote_1.quote)(['git', 'rev-parse', gitBranchName]), { execOptions, logger });
846
+ const candShaResult = await executeWithLog((0, shell_quote_1.quote)(['git', 'rev-parse', branch]), { execOptions, logger });
847
+ const headSha = headShaResult.code === 0 ? headShaResult.stdout.trim() : '';
848
+ const candSha = candShaResult.code === 0 ? candShaResult.stdout.trim() : '';
849
+ if (headSha && headSha === candSha) {
850
+ if (isDebugMode()) {
851
+ logger.log(`[byCommits] KEEP equal-tip parent: ${normalizedBranch} — tips are the same commit ${headSha.slice(0, 9)} (fast-forward merge or fork with no own commits); previously dropped as a descendant`);
852
+ }
853
+ return { name: normalizedBranch, relationship: 'equal-tip' };
854
+ }
855
+ if (headSha) {
856
+ // Bound at HEAD~1, not HEAD: `^<sha>` excludes it, and the range must still contain the tip we look for.
857
+ let candFpResult = await executeWithLog((0, shell_quote_1.quote)(['git', 'rev-list', '--first-parent', branch, `^${headSha}~1`]), { execOptions, logger });
858
+ if (candFpResult.code !== 0) {
859
+ // Root-commit edge (no HEAD~1) — fall back to a capped walk.
860
+ candFpResult = await executeWithLog((0, shell_quote_1.quote)(['git', 'rev-list', '--first-parent', '-n', '5000', branch]), {
861
+ execOptions,
862
+ logger,
863
+ });
864
+ }
865
+ const onFirstParentChain = candFpResult.code === 0 && candFpResult.stdout.split('\n').some(line => line.trim() === headSha);
866
+ if (!onFirstParentChain) {
867
+ if (isDebugMode()) {
868
+ logger.log(`[byCommits] KEEP merge-absorbed parent: ${normalizedBranch} — ${gitBranchName} (${headSha.slice(0, 9)}) is reachable from it only via a merge commit's 2nd parent; the candidate merged this branch in, it was not built on top of it`);
869
+ }
870
+ return { name: normalizedBranch, relationship: 'merge-absorbed' };
871
+ }
872
+ }
829
873
  droppedDescendants++;
830
- if (isDebugMode())
831
- logger.log(`[byCommits] DROP descendant: ${normalizedBranch}`);
874
+ if (isDebugMode()) {
875
+ logger.log(`[byCommits] DROP descendant: ${normalizedBranch} — ${gitBranchName} is an ancestor of it (this branch was merged into / built on top of the candidate, so the candidate is downstream, not a parent)`);
876
+ }
832
877
  return null;
833
878
  }
834
879
  // (c) fork-depth. Compute ALL merge bases of HEAD and the candidate.
@@ -844,12 +889,16 @@ async function getBranchAncestryByCommits({ gitBranchName, defaultBranch, execOp
844
889
  if (mbCs.length === 0) {
845
890
  if (isDebugMode())
846
891
  logger.log(`[byCommits] KEEP (no merge-base resolved, conservative): ${normalizedBranch}`);
847
- return normalizedBranch;
892
+ return { name: normalizedBranch, relationship: 'lineage' };
893
+ }
894
+ const candTipResult = await executeWithLog((0, shell_quote_1.quote)(['git', 'rev-parse', branch]), { execOptions, logger });
895
+ const candTip = candTipResult.code === 0 ? candTipResult.stdout.trim() : '';
896
+ if (candTip && candTip === mergeBase) {
897
+ if (isDebugMode()) {
898
+ logger.log(`[byCommits] KEEP fork-point parent: ${normalizedBranch} — its tip IS the fork point ${mergeBase.slice(0, 9)} (the merge-base with ${gitBranchName}); this branch was checked out from it and the parent has not advanced; previously dropped as a cousin`);
899
+ }
900
+ return { name: normalizedBranch, relationship: 'fork-point' };
848
901
  }
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
902
  let anyStrictlyAboveFloor = false;
854
903
  for (const mbC of mbCs) {
855
904
  if (mbC === mergeBase)
@@ -859,23 +908,47 @@ async function getBranchAncestryByCommits({ gitBranchName, defaultBranch, execOp
859
908
  anyStrictlyAboveFloor = true;
860
909
  break;
861
910
  }
862
- // clean-false → mbC is strictly BELOW the floor (older) → not a fork point
863
- // via this base; keep checking the remaining bases.
864
911
  }
865
912
  if (!anyStrictlyAboveFloor) {
866
913
  droppedCousins++;
867
- if (isDebugMode())
868
- logger.log(`[byCommits] DROP cousin (shares only the floor): ${normalizedBranch}`);
914
+ if (isDebugMode()) {
915
+ logger.log(`[byCommits] DROP cousin (shares only the floor): ${normalizedBranch} — merge-base(${gitBranchName}, ${normalizedBranch}) = [${mbCs.join(', ')}], none is a strict descendant of floor ${mergeBase} (= merge-base with ${defaultBranch}); the branches only meet on the ${defaultBranch} trunk`);
916
+ }
917
+ return null;
918
+ }
919
+ if (fpChain && !mbCs.some(mbC => fpChain.has(mbC))) {
920
+ droppedAbsorbedSiblings++;
921
+ if (isDebugMode()) {
922
+ logger.log(`[byCommits] DROP absorbed-sibling: ${normalizedBranch} — merge-base(s) [${mbCs
923
+ .map(mbC => mbC.slice(0, 9))
924
+ .join(', ')}] reach ${gitBranchName} only via a merge's 2nd parent (content merged into this branch's ancestry), not via its own first-parent line`);
925
+ }
926
+ return null;
927
+ }
928
+ if (candTip && !mbCs.includes(candTip)) {
929
+ droppedDiverged++;
930
+ if (isDebugMode()) {
931
+ logger.log(`[byCommits] DROP diverged sibling: ${normalizedBranch} — has own commits beyond the shared point ${mbCs[0].slice(0, 9)}; a sibling's work, not this branch's ancestry`);
932
+ }
869
933
  return null;
870
934
  }
871
935
  if (isDebugMode())
872
936
  logger.log(`[byCommits] KEEP lineage point: ${normalizedBranch}`);
873
- return normalizedBranch;
937
+ return { name: normalizedBranch, relationship: 'lineage' };
874
938
  });
875
- const finalCleanBranches = finalCandidates.filter((b) => b !== null);
876
- const result = Array.from(new Set(finalCleanBranches)).sort();
939
+ // Dedup by name — the same branch can surface both bare and `origin/`-prefixed.
940
+ const byName = new Map();
941
+ for (const entry of finalCandidates) {
942
+ if (!entry)
943
+ continue;
944
+ const existing = byName.get(entry.name);
945
+ if (!existing || relationshipRank(entry.relationship) < relationshipRank(existing.relationship)) {
946
+ byName.set(entry.name, entry);
947
+ }
948
+ }
949
+ const result = Array.from(byName.values()).sort((a, b) => a.name.localeCompare(b.name));
877
950
  if (isDebugMode()) {
878
- logger.log(`[byCommits] Done. dropped(self=${droppedSelf}, descendants=${droppedDescendants}, cousins=${droppedCousins}) → ${result.length} lineage point(s): ${result.join(', ')}`);
951
+ logger.log(`[byCommits] Done. dropped(self=${droppedSelf}, descendants=${droppedDescendants}, cousins=${droppedCousins}, absorbed-siblings=${droppedAbsorbedSiblings}, diverged=${droppedDiverged}) → ${result.length} related branch(es): ${result.map(entry => `${entry.name}(${entry.relationship})`).join(', ')}`);
879
952
  }
880
953
  return result;
881
954
  }
@@ -890,10 +963,7 @@ exports.getBranchAncestryByCommits = getBranchAncestryByCommits;
890
963
  * Rule 1 (fork-depth: candidate must have forked from HEAD strictly later than
891
964
  * the default branch did — some merge-base with HEAD is a strict descendant of
892
965
  * 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.
966
+ * candidate).
897
967
  *
898
968
  * Walks `refs/heads/` AND `refs/remotes/<remote>/` (stripping the prefix), since
899
969
  * CI typically has one local branch and dozens of remote refs.
@@ -1139,33 +1209,49 @@ const getAllRemoteBranches = utils.general.cachify(async function ({ execOptions
1139
1209
  // Cache by cwd only, not by logger instance.
1140
1210
  args => { var _a; return ({ cwd: (_a = args[0].execOptions) === null || _a === void 0 ? void 0 : _a.cwd }); }, 5 * 60 * 1000);
1141
1211
  /**
1142
- * Determine fetch strategy and execute appropriate fetch operation.
1143
- * No-op when the remote isn't reachable.
1212
+ * Determine fetch strategy and execute the appropriate fetch. No-op when the remote isn't reachable.
1213
+ * `stillShallow` is re-probed after a failed unshallow, so callers can report an incomplete topology.
1144
1214
  */
1145
1215
  async function executeFetchStrategy(isShallow, execOptions, logger) {
1146
1216
  const env = await (0, exports.checkGitEnvironment)({ execOptions, logger });
1147
1217
  if (!env.remoteAccessible) {
1148
1218
  logger.log('executeFetchStrategy: skipping fetch (remote not accessible)');
1149
- return;
1219
+ return {};
1150
1220
  }
1151
1221
  const remoteName = await (0, exports.getPrimaryRemoteName)({ execOptions, logger });
1152
1222
  if (isShallow) {
1153
1223
  // Shallow clone → unshallow for topology discovery.
1154
1224
  logger.log(`Shallow repository detected, unshallowing to enable topology discovery...`);
1155
- await executeWithLog((0, shell_quote_1.quote)(['git', 'fetch', remoteName, '--unshallow', '--filter=blob:none']), {
1225
+ const unshallowResult = await executeWithLog((0, shell_quote_1.quote)(['git', 'fetch', remoteName, '--unshallow', '--filter=blob:none']), {
1156
1226
  execOptions,
1157
1227
  logger,
1158
1228
  });
1159
- logger.log(`Repository unshallowed successfully`);
1229
+ if (unshallowResult.code === 0) {
1230
+ logger.log(`Repository unshallowed successfully`);
1231
+ return {};
1232
+ }
1233
+ logger.log(`Unshallow fetch failed (exit code ${unshallowResult.code})`);
1234
+ const verify = await executeWithLog('git rev-parse --is-shallow-repository', { execOptions, logger });
1235
+ const stillShallow = verify.stdout.trim() === 'true';
1236
+ if (stillShallow) {
1237
+ logger.log(`✗ repository is STILL SHALLOW after the unshallow attempt (exit ${unshallowResult.code}) — topology discovery will be incomplete`);
1238
+ }
1239
+ return { stillShallow };
1160
1240
  }
1161
1241
  else {
1162
1242
  // Single-branch clone → fetch all remote branches for topology discovery.
1163
1243
  logger.log(`Non-shallow clone detected, fetching all remote branches for topology discovery...`);
1164
- await executeWithLog((0, shell_quote_1.quote)(['git', 'fetch', remoteName, '--filter=tree:0']), {
1244
+ const fetchResult = await executeWithLog((0, shell_quote_1.quote)(['git', 'fetch', remoteName, '--filter=tree:0']), {
1165
1245
  execOptions,
1166
1246
  logger,
1167
1247
  });
1168
- logger.log(`All remote branches fetched successfully`);
1248
+ if (fetchResult.code === 0) {
1249
+ logger.log(`All remote branches fetched successfully`);
1250
+ }
1251
+ else {
1252
+ logger.log(`All-branches fetch failed (exit code ${fetchResult.code})`);
1253
+ }
1254
+ return {};
1169
1255
  }
1170
1256
  }
1171
1257
  /**
@@ -1177,7 +1263,7 @@ async function ensureRemoteBranchesAvailable(branchName, isShallow, execOptions,
1177
1263
  const env = await (0, exports.checkGitEnvironment)({ execOptions, logger });
1178
1264
  if (!env.remoteAccessible) {
1179
1265
  logger.log('ensureRemoteBranchesAvailable: skipping (remote not accessible)');
1180
- return;
1266
+ return {};
1181
1267
  }
1182
1268
  try {
1183
1269
  const remoteName = await (0, exports.getPrimaryRemoteName)({ execOptions, logger });
@@ -1188,13 +1274,12 @@ async function ensureRemoteBranchesAvailable(branchName, isShallow, execOptions,
1188
1274
  });
1189
1275
  const isRemoteBranch = symbolicResult.stdout.trim().startsWith(`${remoteName}/`);
1190
1276
  if (!isRemoteBranch) {
1191
- return;
1277
+ return {};
1192
1278
  }
1193
1279
  logger.log(`Detected remote branch scenario, fetching ancestor branches...`);
1194
1280
  // Widen the refspec to fetch all remote branches, then fetch.
1195
1281
  await executeWithLog((0, shell_quote_1.quote)(['git', 'config', `remote.${remoteName}.fetch`, `+refs/heads/*:refs/remotes/${remoteName}/*`]), { execOptions, logger });
1196
- await executeFetchStrategy(isShallow, execOptions, logger);
1197
- logger.log(`Remote branches fetched successfully for complete ancestor check`);
1282
+ const fetchOutcome = await executeFetchStrategy(isShallow, execOptions, logger);
1198
1283
  // Single-branch clones / actions/checkout@v4 never write
1199
1284
  // refs/remotes/<remote>/HEAD. Populate it so extractDefaultBranch step 2
1200
1285
  // (symbolic-ref) succeeds. Non-fatal — steps 3-5 still cover us.
@@ -1204,35 +1289,25 @@ async function ensureRemoteBranchesAvailable(branchName, isShallow, execOptions,
1204
1289
  catch (err) {
1205
1290
  logger.log('ensureRemoteBranchesAvailable: remote set-head -a failed (continuing)', err);
1206
1291
  }
1292
+ return fetchOutcome;
1207
1293
  }
1208
1294
  catch (err) {
1209
1295
  logger.log('Note: Could not determine if branch is remote, continuing with topology discovery', err);
1296
+ return {};
1210
1297
  }
1211
1298
  }
1212
- /**
1213
- * List ancestor branches for `gitBranchName`, ordered by most-recent timestamp.
1214
- *
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.
1223
- *
1224
- * @returns ancestor branches with timestamps, or undefined if skipped.
1225
- */
1226
- exports.extractBranchLookupFallbackList = utils.general.cachify(async function ({ gitBranchName, execOptions, logger = (0, logger_1.makeLogger)(), enableShallowClone = true, }) {
1299
+ const computeBranchLookupFallbackList = utils.general.cachify(async function ({ gitBranchName, execOptions, logger = (0, logger_1.makeLogger)(), enableShallowClone = true, }) {
1227
1300
  const functionStartTime = Date.now();
1228
1301
  logger = logger.extend({ tags: [`extract-branch-fallback-list-${utils.general.shortid()}`] });
1229
1302
  if (isDebugMode())
1230
1303
  logger.log(`[PERF] extractBranchLookupFallbackList started for branch: ${gitBranchName}`);
1304
+ // Findings accumulated en route, attached to whichever terminal failure fires.
1305
+ const findings = [];
1231
1306
  // Env gate. Everything downstream shells out to git.
1232
1307
  const env = await (0, exports.checkGitEnvironment)({ execOptions, logger });
1233
1308
  if (!env.ok) {
1234
1309
  logger.log('extractBranchLookupFallbackList: git environment not ok, skipping');
1235
- return undefined;
1310
+ return { status: 'precondition-failure', diagnostics: { findings: gitEnvironmentFindings(env) } };
1236
1311
  }
1237
1312
  try {
1238
1313
  // 1. Shallow-clone gate: when the caller opted out AND the clone is
@@ -1247,12 +1322,27 @@ exports.extractBranchLookupFallbackList = utils.general.cachify(async function (
1247
1322
  const isShallow = shallowCheckResult.stdout.trim() === 'true';
1248
1323
  if (!enableShallowClone && isShallow) {
1249
1324
  logger.log('Shallow clone detected and enableShallowClone=false, skipping branch lookup');
1250
- return undefined;
1325
+ return {
1326
+ status: 'precondition-failure',
1327
+ diagnostics: {
1328
+ findings: [
1329
+ { code: 'shallow-skip', detail: 'shallow clone and enableShallowClone=false — branch lookup skipped' },
1330
+ ...findings,
1331
+ ...gitEnvironmentFindings(env),
1332
+ ],
1333
+ },
1334
+ };
1251
1335
  }
1252
1336
  // 2. Fetch the other branches' refs so subsequent merge-base calls work
1253
1337
  // (single-branch/shallow clones don't have them locally).
1254
1338
  const ensureRemoteStartTime = Date.now();
1255
- await ensureRemoteBranchesAvailable(gitBranchName, isShallow, execOptions, logger);
1339
+ const remoteAvailability = await ensureRemoteBranchesAvailable(gitBranchName, isShallow, execOptions, logger);
1340
+ if (remoteAvailability.stillShallow) {
1341
+ findings.push({
1342
+ code: 'still-shallow-after-unshallow',
1343
+ detail: 'repository remained shallow after the unshallow attempt — topology discovery incomplete',
1344
+ });
1345
+ }
1256
1346
  if (isDebugMode())
1257
1347
  logger.log(`[PERF] ensureRemoteBranchesAvailable took ${Date.now() - ensureRemoteStartTime}ms`);
1258
1348
  const remoteName = await (0, exports.getPrimaryRemoteName)({ execOptions, logger });
@@ -1270,26 +1360,50 @@ exports.extractBranchLookupFallbackList = utils.general.cachify(async function (
1270
1360
  });
1271
1361
  if (!resolvedDefaultBranch) {
1272
1362
  logger.log(`extractBranchLookupFallbackList: default branch "${defaultBranch}" not resolvable locally or on remote, skipping ancestry`);
1273
- return undefined;
1363
+ return {
1364
+ status: 'precondition-failure',
1365
+ diagnostics: {
1366
+ findings: [
1367
+ {
1368
+ code: 'default-branch-unresolvable',
1369
+ detail: `default branch "${defaultBranch}" not resolvable locally or on the remote`,
1370
+ },
1371
+ ...findings,
1372
+ ...gitEnvironmentFindings(env),
1373
+ ],
1374
+ defaultBranch,
1375
+ },
1376
+ };
1274
1377
  }
1275
1378
  // 4. Discover ancestors: APPLITOOLS_BRANCH_ANCESTRY_STRATEGY=commits|local.
1276
1379
  const ancestryStrategy = (process.env.APPLITOOLS_BRANCH_ANCESTRY_STRATEGY || 'commits').trim().toLowerCase();
1277
- const ancestryDriver = ancestryStrategy === 'local' ? getBranchAncestryByLocalBranches : getBranchAncestryByCommits;
1278
1380
  const ancestryStartTime = Date.now();
1279
1381
  logger.log(`Discovering ancestor branches via "${ancestryStrategy}" strategy for ${gitBranchName} (default=${defaultBranch})...`);
1280
- const ancestry = await ancestryDriver({ gitBranchName, defaultBranch, execOptions, logger });
1382
+ const ancestry = ancestryStrategy === 'local'
1383
+ ? (await getBranchAncestryByLocalBranches({ gitBranchName, defaultBranch, execOptions, logger })).map(name => ({
1384
+ name,
1385
+ relationship: 'unknown',
1386
+ }))
1387
+ : await getBranchAncestryByCommits({ gitBranchName, defaultBranch, execOptions, logger });
1281
1388
  if (isDebugMode())
1282
1389
  logger.log(`[PERF] Ancestry discovery took ${Date.now() - ancestryStartTime}ms`);
1283
1390
  if (isDebugMode()) {
1284
- logger.log(`Ancestry discovery found ${ancestry.length} candidate branches: ${ancestry.join(', ')}`);
1391
+ logger.log(`Ancestry discovery found ${ancestry.length} candidate branches: ${ancestry
1392
+ .map(entry => entry.name)
1393
+ .join(', ')}`);
1285
1394
  }
1286
1395
  else {
1287
1396
  logger.log(`Ancestry discovery found ${ancestry.length} candidate branches`);
1288
1397
  }
1289
1398
  // Strip the current branch (caller adds it back via timestamp logic).
1290
- let allBranches = ancestry.filter(b => b !== gitBranchName);
1399
+ let allBranches = ancestry.filter(entry => entry.name !== gitBranchName);
1400
+ if (isDebugMode() && allBranches.length !== ancestry.length) {
1401
+ logger.log(`[FallbackList] DROP self: ${gitBranchName} — stripped from the ancestor list (it is the branch under evaluation)`);
1402
+ }
1291
1403
  if (isDebugMode()) {
1292
- logger.log(`Ancestry discovered ${allBranches.length} potential ancestor branches: ${allBranches.join(', ')}`);
1404
+ logger.log(`Ancestry discovered ${allBranches.length} potential ancestor branches: ${allBranches
1405
+ .map(entry => entry.name)
1406
+ .join(', ')}`);
1293
1407
  }
1294
1408
  else {
1295
1409
  logger.log(`Ancestry discovered ${allBranches.length} potential ancestor branches`);
@@ -1301,7 +1415,7 @@ exports.extractBranchLookupFallbackList = utils.general.cachify(async function (
1301
1415
  if (isDebugMode()) {
1302
1416
  logger.log('[Remote Filtering] ========================================');
1303
1417
  logger.log('[Remote Filtering] Starting remote branch filtering...');
1304
- logger.log('[Remote Filtering] Branches to check:', allBranches.join(', '));
1418
+ logger.log('[Remote Filtering] Branches to check:', allBranches.map(entry => entry.name).join(', '));
1305
1419
  logger.log('[Remote Filtering] Number of branches before filtering:', allBranches.length);
1306
1420
  }
1307
1421
  try {
@@ -1319,15 +1433,20 @@ exports.extractBranchLookupFallbackList = utils.general.cachify(async function (
1319
1433
  const beforeFilter = allBranches.length;
1320
1434
  if (isDebugMode()) {
1321
1435
  logger.log('[Remote Filtering] Filtering branches against remote...');
1322
- allBranches.forEach(branch => {
1323
- const exists = remoteBranches.has(branch);
1324
- logger.log(`[Remote Filtering] Branch "${branch}": ${exists ? '✓ EXISTS' : '✗ MISSING'} on remote`);
1436
+ allBranches.forEach(entry => {
1437
+ const exists = remoteBranches.has(entry.name);
1438
+ logger.log(`[Remote Filtering] Branch "${entry.name}": ${exists ? '✓ EXISTS' : '✗ MISSING'} on remote`);
1325
1439
  });
1326
1440
  }
1327
- allBranches = allBranches.filter(branch => remoteBranches.has(branch));
1441
+ if (isDebugMode()) {
1442
+ for (const entry of allBranches.filter(candidate => !remoteBranches.has(candidate.name))) {
1443
+ logger.log(`[Remote Filtering] DROP not-on-remote: ${entry.name} — exists locally but has no refs/heads/${entry.name} on the remote (per git ls-remote), so the server could never match a baseline for it`);
1444
+ }
1445
+ }
1446
+ allBranches = allBranches.filter(entry => remoteBranches.has(entry.name));
1328
1447
  const filtered = beforeFilter - allBranches.length;
1329
1448
  if (isDebugMode()) {
1330
- logger.log('[Remote Filtering] Branches after filtering:', allBranches.join(', '));
1449
+ logger.log('[Remote Filtering] Branches after filtering:', allBranches.map(entry => entry.name).join(', '));
1331
1450
  logger.log('[Remote Filtering] Number of branches after filtering:', allBranches.length);
1332
1451
  }
1333
1452
  if (filtered > 0) {
@@ -1354,7 +1473,8 @@ exports.extractBranchLookupFallbackList = utils.general.cachify(async function (
1354
1473
  }
1355
1474
  // 5. Compute each surviving ancestor's branching timestamp in parallel.
1356
1475
  const timestampStartTime = Date.now();
1357
- const branchesWithTimestamps = await Promise.all(allBranches.map(async (ancestorBranch) => {
1476
+ const branchesWithTimestamps = await Promise.all(allBranches.map(async (ancestorEntry) => {
1477
+ const ancestorBranch = ancestorEntry.name;
1358
1478
  let timestamp = await (0, exports.extractBranchingTimestamp)({
1359
1479
  branchName: gitBranchName,
1360
1480
  parentBranchName: ancestorBranch,
@@ -1386,19 +1506,25 @@ exports.extractBranchLookupFallbackList = utils.general.cachify(async function (
1386
1506
  timestamp = tipTimestamp;
1387
1507
  }
1388
1508
  }
1389
- return timestamp ? { branchName: ancestorBranch, latestViableTimestamp: timestamp } : null;
1509
+ if (!timestamp && isDebugMode()) {
1510
+ logger.log(`[Timestamps] DROP no-timestamp: ${ancestorBranch} — no merge-base timestamp after retry AND the tip-commit-date fallback failed (branch unresolvable or non-ISO date); confirmed ancestor is lost from the final list`);
1511
+ }
1512
+ return timestamp
1513
+ ? { branchName: ancestorBranch, latestViableTimestamp: timestamp, relationship: ancestorEntry.relationship }
1514
+ : null;
1390
1515
  }));
1391
1516
  if (isDebugMode())
1392
1517
  logger.log(`[PERF] Timestamp extraction took ${Date.now() - timestampStartTime}ms`);
1393
- // 6. Drop nulls; sort by timestamp desc, tie-break by branch name.
1394
1518
  const validBranches = branchesWithTimestamps
1395
1519
  .filter((item) => item !== null)
1396
1520
  .sort((a, b) => {
1397
- const timeDiff = new Date(b.latestViableTimestamp).getTime() - new Date(a.latestViableTimestamp).getTime();
1398
- if (timeDiff === 0) {
1399
- return a.branchName.localeCompare(b.branchName);
1400
- }
1401
- return timeDiff;
1521
+ const timeDiff = epochOrOldest(b.latestViableTimestamp) - epochOrOldest(a.latestViableTimestamp);
1522
+ if (timeDiff !== 0)
1523
+ return timeDiff;
1524
+ const rankDiff = relationshipRank(a.relationship) - relationshipRank(b.relationship);
1525
+ if (rankDiff !== 0)
1526
+ return rankDiff;
1527
+ return a.branchName.localeCompare(b.branchName);
1402
1528
  });
1403
1529
  // 7. Root-branch safety net (branch containing the repo's first commit).
1404
1530
  try {
@@ -1481,6 +1607,7 @@ exports.extractBranchLookupFallbackList = utils.general.cachify(async function (
1481
1607
  validBranches.push({
1482
1608
  branchName: rootBranch,
1483
1609
  latestViableTimestamp: rootTimestamp,
1610
+ relationship: 'base',
1484
1611
  });
1485
1612
  logger.log(`Added root branch ${rootBranch} with timestamp ${rootTimestamp}`);
1486
1613
  }
@@ -1507,12 +1634,44 @@ exports.extractBranchLookupFallbackList = utils.general.cachify(async function (
1507
1634
  if (isDebugMode()) {
1508
1635
  logger.log(`[PERF] extractBranchLookupFallbackList completed in ${Date.now() - functionStartTime}ms total`);
1509
1636
  }
1510
- logger.log('Successfully extracted branch lookup fallback list', JSON.stringify(validBranches));
1511
- return validBranches.length > 0 ? validBranches : undefined;
1637
+ // Deliberately not debug-gated: backend-side analysis consumes this line. `relationship` is log-only.
1638
+ logger.log(`Branch lookup fallback branches with relationship: ${validBranches
1639
+ .map(branch => `${branch.branchName}(${branch.relationship})`)
1640
+ .join(', ')}`);
1641
+ const wireList = validBranches.map(({ branchName, latestViableTimestamp }) => ({
1642
+ branchName,
1643
+ latestViableTimestamp,
1644
+ }));
1645
+ logger.log('Successfully extracted branch lookup fallback list', JSON.stringify(wireList));
1646
+ if (wireList.length > 0)
1647
+ return { status: 'ok', list: wireList, diagnostics: { findings } };
1648
+ // The default branch legitimately has no ancestors, so an empty list there is not a failure.
1649
+ if (gitBranchName === defaultBranch)
1650
+ return { status: 'ok', diagnostics: { findings, defaultBranch } };
1651
+ return {
1652
+ status: 'precondition-failure',
1653
+ diagnostics: {
1654
+ findings: [
1655
+ { code: 'empty-list', detail: `no fallback branches found for "${gitBranchName}"` },
1656
+ ...findings,
1657
+ ...gitEnvironmentFindings(env),
1658
+ ],
1659
+ defaultBranch,
1660
+ },
1661
+ };
1512
1662
  }
1513
1663
  catch (err) {
1514
1664
  logger.log('Error during extracting branch lookup fallback list', err);
1515
- return undefined;
1665
+ return {
1666
+ status: 'precondition-failure',
1667
+ diagnostics: {
1668
+ findings: [
1669
+ { code: 'extraction-error', detail: err instanceof Error ? err.message : String(err) },
1670
+ ...findings,
1671
+ ...gitEnvironmentFindings(env),
1672
+ ],
1673
+ },
1674
+ };
1516
1675
  }
1517
1676
  }, args => {
1518
1677
  var _a;
@@ -1521,3 +1680,26 @@ exports.extractBranchLookupFallbackList = utils.general.cachify(async function (
1521
1680
  cwd: (_a = args[0].execOptions) === null || _a === void 0 ? void 0 : _a.cwd,
1522
1681
  });
1523
1682
  });
1683
+ /**
1684
+ * List ancestor branches for `gitBranchName`, ordered by most-recent timestamp.
1685
+ *
1686
+ * 1. Shallow-clone gate (skip via `enableShallowClone`, from server account-info).
1687
+ * 2. Fetch remote branches if needed (single-branch/shallow clones).
1688
+ * 3. Detect the actual default branch (`extractDefaultBranch` — non-standard names).
1689
+ * 4. Discover ancestors via `APPLITOOLS_BRANCH_ANCESTRY_STRATEGY`:
1690
+ * `commits` (default, walks MERGE_BASE..HEAD, classified) | `local` (iterates refs, unclassified).
1691
+ * 5. Extract each branch's branching timestamp.
1692
+ * 6. Sort by timestamp desc, tie-break by keep-class rank then name.
1693
+ * 7. Root-branch safety net: fall back to the detected default branch.
1694
+ *
1695
+ * For failure diagnostics call {@link extractBranchLookupFallbackList.withDiagnostics} —
1696
+ * it shares this function's cache entry, so it costs no extra git work.
1697
+ *
1698
+ * @returns ancestor branches with timestamps, or undefined if skipped.
1699
+ */
1700
+ exports.extractBranchLookupFallbackList = Object.assign(async function (options) {
1701
+ return (await computeBranchLookupFallbackList(options)).list;
1702
+ }, {
1703
+ withDiagnostics: (options) => computeBranchLookupFallbackList(options),
1704
+ clearCache: computeBranchLookupFallbackList.clearCache,
1705
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@applitools/core",
3
- "version": "4.67.0",
3
+ "version": "4.67.1",
4
4
  "homepage": "https://applitools.com",
5
5
  "bugs": {
6
6
  "url": "https://github.com/applitools/eyes.sdk.javascript1/issues"
@@ -63,7 +63,7 @@
63
63
  "dependencies": {
64
64
  "@applitools/core-base": "1.37.0",
65
65
  "@applitools/dom-capture": "11.8.4",
66
- "@applitools/dom-snapshot": "4.17.6",
66
+ "@applitools/dom-snapshot": "4.17.7",
67
67
  "@applitools/driver": "1.26.6",
68
68
  "@applitools/ec-client": "1.12.39",
69
69
  "@applitools/logger": "2.3.0",
@@ -23,6 +23,13 @@ type Options<TSpec extends SpecType, TType extends 'classic' | 'ufg'> = {
23
23
  concurrency?: number;
24
24
  useServerConcurrency?: boolean;
25
25
  };
26
+ export declare function sanitizeFinding(finding: {
27
+ code: string;
28
+ detail: string;
29
+ }): {
30
+ code: string;
31
+ detail: string;
32
+ };
26
33
  export declare function makeOpenEyes<TSpec extends SpecType, TDefaultType extends 'classic' | 'ufg' = 'classic'>({ type: defaultType, clients, batch, removeDuplicateTests, core, cores, spec, environment, logger: mainLogger, asyncCache, concurrency, useServerConcurrency, cwd, }: Options<TSpec, TDefaultType>): <TType extends "classic" | "ufg" = TDefaultType>({ type, settings: openSettings, config, target, logger, }: {
27
34
  type?: TType | undefined;
28
35
  settings?: Partial<OpenSettings<TDefaultType> & OpenSettings<TType>> | undefined;
@@ -88,6 +88,8 @@ export declare const checkGitEnvironment: (({ execOptions, logger, }: {
88
88
  clearCache(): void;
89
89
  TTL?: number | undefined;
90
90
  };
91
+ /** The finding `code`s are a telemetry wire contract — Coralogix dashboards match on them; do not rename. */
92
+ export declare function gitEnvironmentFindings(env: GitEnvironmentResult): BranchLookupFinding[];
91
93
  /**
92
94
  * Detect the repo's default branch. Fallback chain (short-circuits on success;
93
95
  * never throws):
@@ -176,37 +178,23 @@ type BranchAncestryOptions = {
176
178
  execOptions?: ExecOptions;
177
179
  logger?: Logger;
178
180
  };
181
+ export type BranchRelationship = 'lineage' | 'fork-point' | 'merge-absorbed' | 'equal-tip' | 'base';
182
+ export type BranchAncestryEntry = {
183
+ name: string;
184
+ relationship: BranchRelationship;
185
+ };
179
186
  /**
180
- * Enumerate ancestor branches in MERGE_BASE..gitBranchName by walking each
181
- * commit in the window and unioning the branches that contain it. Returns a
182
- * sorted, deduped list (no `refs/` prefix). Never throws — `[]` on git failure.
183
- *
184
- * Inclusion contract (fork-depth — shares semantics with
185
- * {@link getBranchAncestryByLocalBranches}):
186
- * - `gitBranchName` is **always excluded** by the self check.
187
- * - `defaultBranch` is **always kept** as the base: it defines the floor
188
- * (`merge-base(HEAD, defaultBranch)`), so its own merge-base with HEAD equals
189
- * the floor and would fail the strict fork-depth test by construction.
190
- * - Descendants of HEAD are dropped (built on top of us; cannot be baselines).
191
- * - A candidate `C` is kept iff it forked from HEAD's history **strictly later**
192
- * than the default branch did — i.e. some `merge-base(HEAD, C)` is a strict
193
- * descendant of the floor. Cousins that share only the floor (diverged no
194
- * later than the default branch) are dropped. This prunes the over-inclusive
195
- * candidate seed (the per-commit `--contains` walk pulls in cousins via
196
- * criss-cross/ghost merges).
197
- *
198
- * Use {@link getBranchAncestryByLocalBranches} if you need both refs pinned.
187
+ * Enumerate RELATED (parent-shaped) branches in MERGE_BASE..gitBranchName,
188
+ * classified by keep-class. Returns a sorted, deduped list (no `refs/` prefix).
189
+ * Never throws — `[]` on git failure.
199
190
  */
200
- export declare function getBranchAncestryByCommits({ gitBranchName, defaultBranch, execOptions, logger, }: BranchAncestryOptions): Promise<string[]>;
191
+ export declare function getBranchAncestryByCommits({ gitBranchName, defaultBranch, execOptions, logger, }: BranchAncestryOptions): Promise<BranchAncestryEntry[]>;
201
192
  /**
202
193
  * Enumerate ancestor branches by iterating every branch ref and applying
203
194
  * Rule 1 (fork-depth: candidate must have forked from HEAD strictly later than
204
195
  * the default branch did — some merge-base with HEAD is a strict descendant of
205
196
  * the primary MERGE_BASE) and Rule 2 (HEAD must not be an ancestor of the
206
- * candidate). Rule 1 shares the fork-depth semantics of
207
- * {@link getBranchAncestryByCommits}: cousins sharing only the floor are dropped
208
- * in BOTH strategies, while the default branch and genuine lineage points are
209
- * kept in both.
197
+ * candidate).
210
198
  *
211
199
  * Walks `refs/heads/` AND `refs/remotes/<remote>/` (stripping the prefix), since
212
200
  * CI typically has one local branch and dozens of remote refs.
@@ -215,6 +203,33 @@ export declare function getBranchAncestryByCommits({ gitBranchName, defaultBranc
215
203
  * includes `defaultBranch` and `gitBranchName`.
216
204
  */
217
205
  export declare function getBranchAncestryByLocalBranches({ gitBranchName, defaultBranch, execOptions, logger, }: BranchAncestryOptions): Promise<string[]>;
206
+ export type BranchLookupFinding = {
207
+ code: string;
208
+ detail: string;
209
+ };
210
+ export type BranchLookupDiagnostics = {
211
+ findings: BranchLookupFinding[];
212
+ defaultBranch?: string;
213
+ };
214
+ export type BranchLookupOutcome = {
215
+ status: 'ok';
216
+ /** Absent when there are legitimately no ancestors. */
217
+ list?: Array<{
218
+ branchName: string;
219
+ latestViableTimestamp: string;
220
+ }>;
221
+ diagnostics: BranchLookupDiagnostics;
222
+ } | {
223
+ status: 'precondition-failure';
224
+ list?: undefined;
225
+ diagnostics: BranchLookupDiagnostics;
226
+ };
227
+ export type BranchLookupFallbackListOptions = {
228
+ gitBranchName: string;
229
+ execOptions?: ExecOptions;
230
+ logger?: Logger;
231
+ enableShallowClone: boolean;
232
+ };
218
233
  /**
219
234
  * List ancestor branches for `gitBranchName`, ordered by most-recent timestamp.
220
235
  *
@@ -222,31 +237,21 @@ export declare function getBranchAncestryByLocalBranches({ gitBranchName, defaul
222
237
  * 2. Fetch remote branches if needed (single-branch/shallow clones).
223
238
  * 3. Detect the actual default branch (`extractDefaultBranch` — non-standard names).
224
239
  * 4. Discover ancestors via `APPLITOOLS_BRANCH_ANCESTRY_STRATEGY`:
225
- * `commits` (default, walks MERGE_BASE..HEAD) | `local` (iterates refs).
240
+ * `commits` (default, walks MERGE_BASE..HEAD, classified) | `local` (iterates refs, unclassified).
226
241
  * 5. Extract each branch's branching timestamp.
227
- * 6. Sort by timestamp desc, tie-break by name.
242
+ * 6. Sort by timestamp desc, tie-break by keep-class rank then name.
228
243
  * 7. Root-branch safety net: fall back to the detected default branch.
229
244
  *
245
+ * For failure diagnostics call {@link extractBranchLookupFallbackList.withDiagnostics} —
246
+ * it shares this function's cache entry, so it costs no extra git work.
247
+ *
230
248
  * @returns ancestor branches with timestamps, or undefined if skipped.
231
249
  */
232
- export declare const extractBranchLookupFallbackList: (({ gitBranchName, execOptions, logger, enableShallowClone, }: {
233
- gitBranchName: string;
234
- execOptions?: ExecOptions | undefined;
235
- logger?: Logger | undefined;
236
- enableShallowClone: boolean;
237
- }) => Promise<Array<{
250
+ export declare const extractBranchLookupFallbackList: ((options: BranchLookupFallbackListOptions) => Promise<Array<{
238
251
  branchName: string;
239
252
  latestViableTimestamp: string;
240
253
  }> | undefined>) & {
241
- getCachedValues(): Promise<{
242
- branchName: string;
243
- latestViableTimestamp: string;
244
- }[] | undefined>[];
245
- setCachedValue(key: any, value: Promise<{
246
- branchName: string;
247
- latestViableTimestamp: string;
248
- }[] | undefined>): void;
249
- clearCache(): void;
250
- TTL?: number | undefined;
254
+ withDiagnostics: (options: BranchLookupFallbackListOptions) => Promise<BranchLookupOutcome>;
255
+ clearCache: () => void;
251
256
  };
252
257
  export {};