@applitools/core 4.65.2 → 4.66.0

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