hillclimb 0.9.5 → 0.9.6

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.
Files changed (2) hide show
  1. package/dist/main.js +421 -59
  2. package/package.json +1 -1
package/dist/main.js CHANGED
@@ -15183,13 +15183,14 @@ import os9 from "os";
15183
15183
  import path19 from "path";
15184
15184
 
15185
15185
  // src/git-traces/git-ops.ts
15186
- import { execFileSync as execFileSync2, spawn as spawn2, spawnSync } from "child_process";
15186
+ import { execFileSync as execFileSync2, spawn as spawn2, spawnSync as spawnSync2 } from "child_process";
15187
15187
  import fs18 from "fs";
15188
15188
  import os7 from "os";
15189
15189
  import path17 from "path";
15190
15190
  import { createGzip } from "zlib";
15191
15191
 
15192
15192
  // src/git-traces/object-store-probe.ts
15193
+ import { spawnSync } from "child_process";
15193
15194
  import fs17 from "fs";
15194
15195
  import path16 from "path";
15195
15196
  var probedRepos = /* @__PURE__ */ new Set();
@@ -15197,7 +15198,16 @@ var SAMPLE_FANOUT_DIRS = 8;
15197
15198
  var SAMPLE_READS = 20;
15198
15199
  var SAMPLE_READ_BYTES = 4096;
15199
15200
  var MAX_NAMED_OBJECTS = 3;
15200
- function probeObjectStore(repoRoot, trigger, stderr) {
15201
+ var MAX_LOCATE_BLOBS = 2e4;
15202
+ var MAX_STALLED_PATH_CHARS = 120;
15203
+ var OBJECT_SHA = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/;
15204
+ var gitLimits = {
15205
+ versionMs: 2e3,
15206
+ listMs: 1e4,
15207
+ readMs: 15e3,
15208
+ headMs: 5e3
15209
+ };
15210
+ function probeObjectStore(repoRoot, trigger, stderr, args = []) {
15201
15211
  if (probedRepos.has(repoRoot)) return;
15202
15212
  probedRepos.add(repoRoot);
15203
15213
  const started = Date.now();
@@ -15226,13 +15236,19 @@ function probeObjectStore(repoRoot, trigger, stderr) {
15226
15236
  `alternates=${fs17.existsSync(path16.join(objectsDir, "info", "alternates")) ? "yes" : "no"}`
15227
15237
  );
15228
15238
  fields.push(...timeSampleReads(sample));
15229
- for (const sha of namedObjects(stderr)) {
15239
+ const named = namedObjects(stderr);
15240
+ for (const sha of named) {
15230
15241
  const loose = path16.join(objectsDir, sha.slice(0, 2), sha.slice(2));
15231
15242
  const stats = fs17.statSync(loose, { throwIfNoEntry: false });
15232
15243
  fields.push(
15233
15244
  stats ? `named=${sha.slice(0, 8)}(size=${stats.size},blocks=${stats.blocks})` : `named=${sha.slice(0, 8)}(no loose file)`
15234
15245
  );
15235
15246
  }
15247
+ fields.push(...gitVersion(repoRoot));
15248
+ locateGitRead(fields, repoRoot, objectsDir, failedTreeish(args), [
15249
+ ...sample.map(sampleSha).filter((sha) => !!sha),
15250
+ ...named
15251
+ ]);
15236
15252
  } catch (err) {
15237
15253
  fields.push(
15238
15254
  `probeError=${err instanceof Error ? err.message : String(err)}`
@@ -15285,6 +15301,7 @@ function timeSampleReads(sample) {
15285
15301
  const readStarted = performance.now();
15286
15302
  try {
15287
15303
  const stats = fs17.statSync(file);
15304
+ if (!stats.isFile()) continue;
15288
15305
  if (stats.size > 0 && stats.blocks === 0) dataless++;
15289
15306
  const fd = fs17.openSync(file, "r");
15290
15307
  try {
@@ -15314,6 +15331,121 @@ function namedObjects(stderr) {
15314
15331
  const shas = new Set(stderr?.match(/\b[0-9a-f]{40}\b/g) ?? []);
15315
15332
  return [...shas].slice(0, MAX_NAMED_OBJECTS);
15316
15333
  }
15334
+ function sampleSha(file) {
15335
+ const sha = path16.basename(path16.dirname(file)) + path16.basename(file);
15336
+ return OBJECT_SHA.test(sha) ? sha : null;
15337
+ }
15338
+ function failedTreeish(args) {
15339
+ for (const arg of args.slice(1).reverse()) {
15340
+ if (OBJECT_SHA.test(arg) || arg.startsWith("refs/")) return arg;
15341
+ }
15342
+ return null;
15343
+ }
15344
+ function probeGit(repoRoot, args, timeout, input) {
15345
+ return spawnSync("git", args, {
15346
+ cwd: repoRoot,
15347
+ input,
15348
+ timeout,
15349
+ killSignal: "SIGKILL",
15350
+ maxBuffer: 64 * 1024 * 1024,
15351
+ windowsHide: true,
15352
+ // Never prompt or lazily fetch from inside a diagnostic.
15353
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_NO_LAZY_FETCH: "1" }
15354
+ });
15355
+ }
15356
+ function gitVersion(repoRoot) {
15357
+ const started = performance.now();
15358
+ const result = probeGit(repoRoot, ["--version"], gitLimits.versionMs);
15359
+ const version2 = result.stdout?.toString().trim().replace(/^git version /, "");
15360
+ return [
15361
+ `gitVersion=${result.status === 0 && version2 ? version2 : `unavailable(${result.error ? result.error.code : result.status})`}`,
15362
+ `gitSpawnMs=${(performance.now() - started).toFixed(0)}`
15363
+ ];
15364
+ }
15365
+ function locateGitRead(fields, repoRoot, objectsDir, treeish, fallback) {
15366
+ let targets = null;
15367
+ if (treeish) {
15368
+ const listed = probeGit(
15369
+ repoRoot,
15370
+ ["ls-tree", "-r", "-z", treeish],
15371
+ gitLimits.listMs
15372
+ );
15373
+ if (listed.status === 0 && listed.stdout) {
15374
+ targets = treeBlobs(listed.stdout.toString());
15375
+ fields.push(
15376
+ `gitReadTarget=tree:${OBJECT_SHA.test(treeish) ? treeish.slice(0, 8) : treeish}`,
15377
+ `treeBlobs=${targets.length}`
15378
+ );
15379
+ targets = targets.slice(0, MAX_LOCATE_BLOBS);
15380
+ } else {
15381
+ fields.push("gitReadTarget=sample", `lsTreeError=${failureText(listed)}`);
15382
+ }
15383
+ } else {
15384
+ fields.push("gitReadTarget=sample");
15385
+ }
15386
+ targets ??= [...new Set(fallback)].map((sha) => ({ sha }));
15387
+ if (targets.length === 0) return;
15388
+ const started = performance.now();
15389
+ const read = probeGit(
15390
+ repoRoot,
15391
+ ["cat-file", "--batch-check=%(objectname) %(objecttype) %(objectsize)"],
15392
+ gitLimits.readMs,
15393
+ `${targets.map((target) => target.sha).join("\n")}
15394
+ `
15395
+ );
15396
+ const answered = (read.stdout?.toString() ?? "").split("\n").slice(0, -1);
15397
+ fields.push(
15398
+ `gitRead=${answered.length}/${targets.length}`,
15399
+ `gitReadMs=${(performance.now() - started).toFixed(1)}`,
15400
+ `gitMissing=${answered.filter((line) => line.endsWith(" missing")).length}`
15401
+ );
15402
+ const stalled = targets[answered.length];
15403
+ if (!stalled) return;
15404
+ fields.push(
15405
+ read.error?.code === "ETIMEDOUT" ? `gitReadTimeout=${gitLimits.readMs}` : `gitReadError=${failureText(read)}`,
15406
+ `gitStalledOn=${stalled.sha.slice(0, 8)}`
15407
+ );
15408
+ if (stalled.path)
15409
+ fields.push(
15410
+ `stalledPath=${JSON.stringify(stalled.path.slice(0, MAX_STALLED_PATH_CHARS))}`
15411
+ );
15412
+ const loose = path16.join(
15413
+ objectsDir,
15414
+ stalled.sha.slice(0, 2),
15415
+ stalled.sha.slice(2)
15416
+ );
15417
+ const stats = fs17.statSync(loose, { throwIfNoEntry: false });
15418
+ fields.push(
15419
+ stats ? `stalledLoose=size=${stats.size},blocks=${stats.blocks}` : "stalledLoose=no"
15420
+ );
15421
+ if (stats && process.platform !== "win32") {
15422
+ const headStarted = performance.now();
15423
+ const head = spawnSync("head", ["-c", "4096", loose], {
15424
+ timeout: gitLimits.headMs,
15425
+ killSignal: "SIGKILL"
15426
+ });
15427
+ fields.push(
15428
+ head.error?.code === "ETIMEDOUT" ? `headTimeout=${gitLimits.headMs}` : `headMs=${(performance.now() - headStarted).toFixed(0)}`
15429
+ );
15430
+ }
15431
+ }
15432
+ function treeBlobs(listing) {
15433
+ const blobs = /* @__PURE__ */ new Map();
15434
+ for (const entry of listing.split("\0")) {
15435
+ const tab = entry.indexOf(" ");
15436
+ if (tab < 0) continue;
15437
+ const [, type, sha] = entry.slice(0, tab).split(" ");
15438
+ if (type === "blob" && sha && !blobs.has(sha))
15439
+ blobs.set(sha, entry.slice(tab + 1));
15440
+ }
15441
+ return [...blobs].map(([sha, blobPath]) => ({ sha, path: blobPath }));
15442
+ }
15443
+ function failureText(result) {
15444
+ const error = result.error;
15445
+ if (error) return error.code ?? error.message;
15446
+ const stderr = result.stderr?.toString().trim().split("\n")[0];
15447
+ return stderr || `exit=${result.status}`;
15448
+ }
15317
15449
 
15318
15450
  // src/git-traces/git-ops.ts
15319
15451
  var DEFAULT_GIT_COMMAND_TIMEOUT_MS = 12e4;
@@ -15514,7 +15646,7 @@ function isBinaryBuffer(buffer) {
15514
15646
  }
15515
15647
  function readTreeBlobHead(repoRoot, sha) {
15516
15648
  try {
15517
- const { stdout } = spawnSync("git", ["cat-file", "blob", sha], {
15649
+ const { stdout } = spawnSync2("git", ["cat-file", "blob", sha], {
15518
15650
  cwd: repoRoot,
15519
15651
  timeout: GIT_COMMAND_TIMEOUT_MS,
15520
15652
  maxBuffer: BINARY_SNIFF_BYTES,
@@ -15634,6 +15766,7 @@ function isGitTimeoutError(err) {
15634
15766
  }
15635
15767
  function gitBuffer(repoRoot, args, options = {}) {
15636
15768
  const timeout = options.timeoutMs ?? EXEC_OPTS.timeout;
15769
+ const detached = options.killProcessGroup && process.platform !== "win32";
15637
15770
  const started = Date.now();
15638
15771
  try {
15639
15772
  return execFileSync2("git", args, {
@@ -15642,15 +15775,25 @@ function gitBuffer(repoRoot, args, options = {}) {
15642
15775
  ...options.input !== void 0 ? { input: options.input } : {},
15643
15776
  stdio: ["pipe", "pipe", "pipe"],
15644
15777
  ...EXEC_OPTS,
15645
- timeout
15778
+ timeout,
15779
+ // Not in the sync typings, but spawnSync honors it (setsid).
15780
+ ...detached ? { detached: true } : {}
15646
15781
  });
15647
15782
  } catch (err) {
15783
+ const pid = err.pid;
15784
+ if (detached && pid) {
15785
+ try {
15786
+ process.kill(-pid, "SIGKILL");
15787
+ } catch {
15788
+ }
15789
+ }
15648
15790
  const wrapped = createGitError(args, err, timeout, Date.now() - started);
15649
- if (wrapped.isGitTimeout || wrapped.isGitMissingObject) {
15791
+ if (wrapped.isGitTimeout || wrapped.isGitMissingObject && !options.expectMissingObjects) {
15650
15792
  probeObjectStore(
15651
15793
  repoRoot,
15652
15794
  wrapped.isGitTimeout ? "timeout" : "missing-object",
15653
- wrapped.gitStderr
15795
+ wrapped.gitStderr,
15796
+ args
15654
15797
  );
15655
15798
  }
15656
15799
  throw wrapped;
@@ -15783,7 +15926,7 @@ function captureWorkingCommitShaOnIndex(repoRoot, indexEnv) {
15783
15926
  function hasUnbornHead(repoRoot) {
15784
15927
  const ref = git(repoRoot, ["symbolic-ref", "--quiet", "HEAD"]);
15785
15928
  const args = ["show-ref", "--verify", "--quiet", ref];
15786
- const result = spawnSync("git", args, { cwd: repoRoot, ...EXEC_OPTS });
15929
+ const result = spawnSync2("git", args, { cwd: repoRoot, ...EXEC_OPTS });
15787
15930
  if (result.error) throw createGitError(args, result.error);
15788
15931
  return result.status === 1;
15789
15932
  }
@@ -15802,20 +15945,99 @@ function deleteRef(repoRoot, refName) {
15802
15945
  function captureSnapshotSha(repoRoot) {
15803
15946
  return captureWorkingCommitSha(repoRoot);
15804
15947
  }
15805
- function snapshotBlobInventory(repoRoot, treeish) {
15806
- const blobs = /* @__PURE__ */ new Map();
15948
+ var OBJECT_SHA2 = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/;
15949
+ function gitExpectingMissing(repoRoot, args) {
15950
+ return gitBuffer(repoRoot, args, { expectMissingObjects: true }).toString("utf-8").trim();
15951
+ }
15952
+ function commitTreeSha(repoRoot, commit) {
15953
+ const tree = gitExpectingMissing(repoRoot, [
15954
+ "rev-list",
15955
+ "--no-walk",
15956
+ "--format=%T",
15957
+ commit,
15958
+ "--"
15959
+ ]).split("\n").at(-1);
15960
+ return tree && OBJECT_SHA2.test(tree) ? tree : null;
15961
+ }
15962
+ function listUnsharedObjects(repoRoot, treeSha, baseTrees) {
15963
+ const out = gitExpectingMissing(repoRoot, [
15964
+ "rev-list",
15965
+ "--objects",
15966
+ treeSha,
15967
+ ...baseTrees.map((base) => `^${base}`),
15968
+ "--"
15969
+ ]);
15970
+ const shas = /* @__PURE__ */ new Set();
15971
+ for (const line of out.split("\n")) {
15972
+ const sha = line.split(" ", 1)[0];
15973
+ if (OBJECT_SHA2.test(sha)) shas.add(sha);
15974
+ }
15975
+ return [...shas];
15976
+ }
15977
+ function walkBaseTree(repoRoot, tree) {
15978
+ gitExpectingMissing(repoRoot, ["rev-list", "--objects", `^${tree}`, "--"]);
15979
+ }
15980
+ function blobSizeTotals(repoRoot, shas) {
15981
+ const totals = { bytes: 0, blobs: 0, unreadable: 0 };
15982
+ for (const object of batchCheckObjects(repoRoot, shas)) {
15983
+ if (object.type === "missing") totals.unreadable++;
15984
+ else if (object.type === "blob" && object.size !== null) {
15985
+ totals.bytes += object.size;
15986
+ totals.blobs++;
15987
+ }
15988
+ }
15989
+ return totals;
15990
+ }
15991
+ var gitVersionCache;
15992
+ function gitVersion2(repoRoot) {
15993
+ if (gitVersionCache !== void 0) return gitVersionCache;
15994
+ try {
15995
+ const match = /git version (\d+)\.(\d+)(?:\.(\d+))?/.exec(
15996
+ git(repoRoot, ["--version"])
15997
+ );
15998
+ gitVersionCache = match ? [Number(match[1]), Number(match[2]), Number(match[3] ?? 0)] : null;
15999
+ } catch {
16000
+ gitVersionCache = null;
16001
+ }
16002
+ return gitVersionCache;
16003
+ }
16004
+ function gitExcludesNegativeTrees(repoRoot) {
16005
+ const version2 = gitVersion2(repoRoot);
16006
+ if (!version2) return true;
16007
+ const [major, minor, patch] = version2;
16008
+ return major > 1 || minor > 8 || minor === 8 && patch >= 5;
16009
+ }
16010
+ function treeBlobSizes(repoRoot, treeSha) {
16011
+ const sizes = /* @__PURE__ */ new Map();
16012
+ for (const entry of lsTreeEntries(repoRoot, ["-l"], treeSha)) {
16013
+ if (entry.type === "blob" && !sizes.has(entry.sha))
16014
+ sizes.set(
16015
+ entry.sha,
16016
+ /^\d+$/.test(entry.size) ? Number(entry.size) : null
16017
+ );
16018
+ }
16019
+ return sizes;
16020
+ }
16021
+ function treeBlobShas(repoRoot, treeSha) {
16022
+ const shas = /* @__PURE__ */ new Set();
16023
+ for (const entry of lsTreeEntries(repoRoot, [], treeSha)) {
16024
+ if (entry.type === "blob") shas.add(entry.sha);
16025
+ }
16026
+ return shas;
16027
+ }
16028
+ function lsTreeEntries(repoRoot, flags, treeSha) {
16029
+ const entries = [];
15807
16030
  for (const entry of gitBuffer(repoRoot, [
15808
16031
  "ls-tree",
15809
16032
  "-r",
15810
- "-l",
16033
+ ...flags,
15811
16034
  "-z",
15812
- treeish
16035
+ treeSha
15813
16036
  ]).toString("utf-8").split("\0")) {
15814
- const [, type, sha, size] = entry.slice(0, entry.indexOf(" ")).trim().split(/\s+/);
15815
- if (type === "blob" && sha && /^\d+$/.test(size ?? ""))
15816
- blobs.set(sha, Number(size));
16037
+ const [, type, sha, size = ""] = entry.slice(0, entry.indexOf(" ")).trim().split(/\s+/);
16038
+ if (type && sha) entries.push({ type, sha, size });
15817
16039
  }
15818
- return blobs;
16040
+ return entries;
15819
16041
  }
15820
16042
  function formatSize(bytes) {
15821
16043
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
@@ -15887,20 +16109,28 @@ function parseDiffTreeRawZ(output) {
15887
16109
  }
15888
16110
  function lookupBlobSizes(repoRoot, shas) {
15889
16111
  const sizes = /* @__PURE__ */ new Map();
15890
- if (shas.length === 0) return sizes;
16112
+ for (const object of batchCheckObjects(repoRoot, shas)) {
16113
+ if (object.type === "blob" && object.size !== null)
16114
+ sizes.set(object.sha, object.size);
16115
+ }
16116
+ return sizes;
16117
+ }
16118
+ function batchCheckObjects(repoRoot, shas) {
16119
+ if (shas.length === 0) return [];
15891
16120
  const out = gitBuffer(
15892
16121
  repoRoot,
15893
16122
  ["cat-file", "--batch-check=%(objectname) %(objecttype) %(objectsize)"],
15894
16123
  { input: `${shas.join("\n")}
15895
16124
  ` }
15896
16125
  ).toString("utf-8");
16126
+ const objects = [];
15897
16127
  for (const line of out.split("\n")) {
15898
16128
  const [sha, type, sizeRaw] = line.split(" ");
15899
- if (type !== "blob") continue;
16129
+ if (!sha || !type) continue;
15900
16130
  const size = Number.parseInt(sizeRaw, 10);
15901
- if (Number.isFinite(size)) sizes.set(sha, size);
16131
+ objects.push({ sha, type, size: Number.isFinite(size) ? size : null });
15902
16132
  }
15903
- return sizes;
16133
+ return objects;
15904
16134
  }
15905
16135
  function sortOmittedByPath(files) {
15906
16136
  return [...files].sort(
@@ -16276,7 +16506,8 @@ function createBundleFromTree(repoRoot, treeSha, sessionId, label) {
16276
16506
  repoRoot,
16277
16507
  "bundle-create",
16278
16508
  () => gitBuffer(repoRoot, ["bundle", "create", tmpFile, orphanRef], {
16279
- timeoutMs: resolveGitCommandTimeoutMs(DEFAULT_BUNDLE_TIMEOUT_MS)
16509
+ timeoutMs: resolveGitCommandTimeoutMs(DEFAULT_BUNDLE_TIMEOUT_MS),
16510
+ killProcessGroup: true
16280
16511
  })
16281
16512
  );
16282
16513
  const bundle = fs18.readFileSync(tmpFile);
@@ -16335,7 +16566,7 @@ function gitGzipStream(repoRoot, args, maxGzBytes) {
16335
16566
  GIT_COMMAND_TIMEOUT_MS,
16336
16567
  Date.now() - started
16337
16568
  );
16338
- probeObjectStore(repoRoot, "timeout", wrapped.gitStderr);
16569
+ probeObjectStore(repoRoot, "timeout", wrapped.gitStderr, args);
16339
16570
  reject(wrapped);
16340
16571
  } else if (overflowed) {
16341
16572
  reject(
@@ -17035,6 +17266,13 @@ function replayBackoffMs(attempts) {
17035
17266
  REPLAY_BACKOFF_MAX_MS
17036
17267
  );
17037
17268
  }
17269
+ function bundleBackoffRemainingMs(timeout, now) {
17270
+ if (!timeout) return 0;
17271
+ return Math.max(
17272
+ 0,
17273
+ timeout.lastAttemptAt + replayBackoffMs(timeout.attempts) - now
17274
+ );
17275
+ }
17038
17276
  function replayEligibility(oldestCapturedAt, replay, now) {
17039
17277
  if (now - oldestCapturedAt < STRANDED_MIN_AGE_MS)
17040
17278
  return { eligible: false, reason: "young" };
@@ -17216,7 +17454,7 @@ function queueDiskBytes(directory) {
17216
17454
  return diskBytes;
17217
17455
  }
17218
17456
  function chargedBytes(record) {
17219
- return record.retainedBytes ?? record.treeBytes;
17457
+ return record.retainedBytes ?? record.treeBytes ?? 0;
17220
17458
  }
17221
17459
  function pendingBytes(records) {
17222
17460
  const first = records[0];
@@ -17228,42 +17466,109 @@ function pendingBytes(records) {
17228
17466
  0
17229
17467
  );
17230
17468
  }
17469
+ function warnMissingBase(repoRoot, base) {
17470
+ appendLog(
17471
+ "warn",
17472
+ `git-traces: capture base is missing for retention accounting; charging unshared content (repo=${repoRoot}, base=${base})`
17473
+ );
17474
+ }
17231
17475
  function measureTreeRetention(repoRoot, treeSha, bases, context) {
17232
17476
  const finish = startCaptureStage("tree-inventory", context);
17233
17477
  let status = "failed";
17234
17478
  let details;
17235
17479
  try {
17236
- const blobs = snapshotBlobInventory(repoRoot, treeSha);
17237
- const shared = /* @__PURE__ */ new Set();
17238
- let basesRead = 0;
17239
- for (const base of new Set(
17240
- bases.filter((base2) => !!base2)
17241
- )) {
17480
+ const trees = [
17481
+ ...new Set(bases.trees.filter((base) => !!base))
17482
+ ];
17483
+ if (!trees.includes(treeSha) && bases.headSha) {
17242
17484
  try {
17243
- const baseBlobs = base === treeSha ? blobs : snapshotBlobInventory(repoRoot, base);
17244
- for (const sha of baseBlobs.keys()) shared.add(sha);
17245
- basesRead++;
17485
+ const headTree = commitTreeSha(repoRoot, bases.headSha);
17486
+ if (headTree && !trees.includes(headTree)) trees.push(headTree);
17487
+ if (!headTree) warnMissingBase(repoRoot, bases.headSha);
17246
17488
  } catch (err) {
17247
17489
  if (!isMissingObjectError(err)) throw err;
17248
- appendLog(
17249
- "warn",
17250
- `git-traces: capture base is missing for retention accounting; charging unshared content (repo=${repoRoot}, base=${base})`
17251
- );
17490
+ warnMissingBase(repoRoot, bases.headSha);
17252
17491
  }
17253
17492
  }
17254
- let treeBytes = 0;
17255
- let retainedBytes = 0;
17256
- for (const [sha, size] of blobs) {
17257
- treeBytes += size;
17258
- if (!shared.has(sha)) retainedBytes += size;
17493
+ if (trees.includes(treeSha)) {
17494
+ status = "ok";
17495
+ details = "retainedBytes=0, sameAsBase=yes";
17496
+ return 0;
17259
17497
  }
17498
+ let totals;
17499
+ if (gitExcludesNegativeTrees(repoRoot)) {
17500
+ const { objects, usable } = unsharedObjects(repoRoot, treeSha, trees);
17501
+ totals = { ...blobSizeTotals(repoRoot, objects), used: usable };
17502
+ } else {
17503
+ totals = legacyRetention(repoRoot, treeSha, trees);
17504
+ }
17505
+ const { used } = totals;
17260
17506
  status = "ok";
17261
- details = `treeBytes=${treeBytes}, treeFiles=${blobs.size}, retainedBytes=${retainedBytes}, bases=${basesRead}`;
17262
- return { treeBytes, treeFiles: blobs.size, retainedBytes };
17507
+ details = `retainedBytes=${totals.bytes}, retainedBlobs=${totals.blobs}, bases=${used}`;
17508
+ if (totals.unreadable > 0)
17509
+ details += `, unreadableBlobs=${totals.unreadable}`;
17510
+ if (used < trees.length) details += `, droppedBases=${trees.length - used}`;
17511
+ return totals.bytes;
17263
17512
  } finally {
17264
17513
  finish(status, details);
17265
17514
  }
17266
17515
  }
17516
+ function legacyRetention(repoRoot, treeSha, bases) {
17517
+ appendLog(
17518
+ "info",
17519
+ `git-traces: git before 1.8.5 ignores negative trees; measuring retention from ls-tree (repo=${repoRoot})`
17520
+ );
17521
+ const shared = /* @__PURE__ */ new Set();
17522
+ let used = 0;
17523
+ for (const base of bases) {
17524
+ try {
17525
+ for (const sha of treeBlobShas(repoRoot, base)) shared.add(sha);
17526
+ used++;
17527
+ } catch (err) {
17528
+ if (!isMissingObjectError(err)) throw err;
17529
+ warnMissingBase(repoRoot, base);
17530
+ }
17531
+ }
17532
+ const totals = { bytes: 0, blobs: 0, unreadable: 0, used };
17533
+ for (const [sha, size] of treeBlobSizes(repoRoot, treeSha)) {
17534
+ if (shared.has(sha)) continue;
17535
+ if (size === null) totals.unreadable++;
17536
+ else {
17537
+ totals.bytes += size;
17538
+ totals.blobs++;
17539
+ }
17540
+ }
17541
+ return totals;
17542
+ }
17543
+ function unsharedObjects(repoRoot, treeSha, bases) {
17544
+ try {
17545
+ return {
17546
+ objects: listUnsharedObjects(repoRoot, treeSha, bases),
17547
+ usable: bases.length
17548
+ };
17549
+ } catch (err) {
17550
+ if (!isMissingObjectError(err) || bases.length === 0) throw err;
17551
+ const usable = bases.filter((base) => {
17552
+ try {
17553
+ walkBaseTree(repoRoot, base);
17554
+ return true;
17555
+ } catch (baseErr) {
17556
+ if (!isMissingObjectError(baseErr)) throw baseErr;
17557
+ warnMissingBase(repoRoot, base);
17558
+ return false;
17559
+ }
17560
+ });
17561
+ if (usable.length === bases.length)
17562
+ return {
17563
+ objects: listUnsharedObjects(repoRoot, treeSha, bases),
17564
+ usable: bases.length
17565
+ };
17566
+ return {
17567
+ objects: listUnsharedObjects(repoRoot, treeSha, usable),
17568
+ usable: usable.length
17569
+ };
17570
+ }
17571
+ }
17267
17572
  async function withCaptureQueueLock(repoRoot, tool, sessionId, work) {
17268
17573
  const lockTool = `${tool}:capture-queue-v1`;
17269
17574
  await acquireLock3(repoRoot, lockTool, sessionId);
@@ -17287,21 +17592,26 @@ function storePendingCapture(params) {
17287
17592
  params.tool,
17288
17593
  params.sessionId
17289
17594
  );
17290
- const retention = measureTreeRetention(
17595
+ const retainedBytes = measureTreeRetention(
17291
17596
  params.repoRoot,
17292
17597
  input.treeSha,
17293
- [
17294
- existing.at(-1)?.treeSha,
17295
- params.seed.lastSnapshotTreeSha,
17296
- params.seed.baselineTreeSha,
17297
- input.headSha
17298
- ],
17598
+ {
17599
+ trees: [
17600
+ existing.at(-1)?.treeSha,
17601
+ params.seed.lastSnapshotTreeSha,
17602
+ params.seed.baselineTreeSha
17603
+ ],
17604
+ headSha: input.headSha
17605
+ },
17299
17606
  `repo=${params.repoRoot}, tool=${params.tool}, session=${params.sessionId}, recordedAt=${params.recordedAt}`
17300
17607
  );
17301
17608
  const record = {
17302
17609
  ...identity,
17303
17610
  ...input,
17304
- ...retention,
17611
+ retainedBytes,
17612
+ // 0.8.12 sums treeBytes with no fallback; a missing value would
17613
+ // disable its byte limit for the whole queue.
17614
+ treeBytes: retainedBytes,
17305
17615
  version: 1,
17306
17616
  id: crypto7.randomUUID(),
17307
17617
  sequence: Math.max(Date.now(), (existing.at(-1)?.sequence ?? 0) + 1),
@@ -17310,10 +17620,10 @@ function storePendingCapture(params) {
17310
17620
  const pendingBefore = queueDiskBytes(
17311
17621
  queueDirectory(params.repoRoot, params.tool, params.sessionId)
17312
17622
  ) + existing.reduce((sum, item) => sum + chargedBytes(item), 0);
17313
- const incomingBytes = retention.retainedBytes + Buffer.byteLength(JSON.stringify(record));
17623
+ const incomingBytes = retainedBytes + Buffer.byteLength(JSON.stringify(record));
17314
17624
  if (existing.length >= MAX_CAPTURES || pendingBefore + incomingBytes > MAX_PENDING_BYTES2) {
17315
17625
  throw new PendingCaptureLimitError(
17316
- `Pending Git capture limit reached (count=${existing.length}, maxCount=${MAX_CAPTURES}, pendingBytes=${pendingBefore}, incomingBytes=${incomingBytes}, treeBytes=${retention.treeBytes}, maxBytes=${MAX_PENDING_BYTES2}); existing captures retained`
17626
+ `Pending Git capture limit reached (count=${existing.length}, maxCount=${MAX_CAPTURES}, pendingBytes=${pendingBefore}, incomingBytes=${incomingBytes}, retainedBytes=${retainedBytes}, maxBytes=${MAX_PENDING_BYTES2}); existing captures retained`
17317
17627
  );
17318
17628
  }
17319
17629
  const directory = captureDirectory(record);
@@ -17355,7 +17665,7 @@ function storePendingCapture(params) {
17355
17665
  durableDetails = `capture=${record.id}, durableCapturedAt=${durableCapturedAt}`;
17356
17666
  appendLog(
17357
17667
  "info",
17358
- `git-traces: capture queued (repo=${record.repoRoot}, session=${record.sessionId}, capture=${record.id}, recordedAt=${record.recordedAt}, captureStartedAt=${record.captureStartedAt}, capturedAt=${record.capturedAt}, sourceCapturedAt=${record.capturedAt}, durableCapturedAt=${durableCapturedAt}, pending=${existing.length + 1}, oldestPendingAgeMs=${Date.now() - (existing[0]?.capturedAt ?? record.capturedAt)}, treeBytes=${record.treeBytes}, treeFiles=${record.treeFiles}, retainedBytes=${record.retainedBytes}, pendingBytes=${pendingBefore + incomingBytes})`
17668
+ `git-traces: capture queued (repo=${record.repoRoot}, session=${record.sessionId}, capture=${record.id}, recordedAt=${record.recordedAt}, captureStartedAt=${record.captureStartedAt}, capturedAt=${record.capturedAt}, sourceCapturedAt=${record.capturedAt}, durableCapturedAt=${durableCapturedAt}, pending=${existing.length + 1}, oldestPendingAgeMs=${Date.now() - (existing[0]?.capturedAt ?? record.capturedAt)}, retainedBytes=${record.retainedBytes}, pendingBytes=${pendingBefore + incomingBytes})`
17359
17669
  );
17360
17670
  return record;
17361
17671
  } finally {
@@ -17546,7 +17856,7 @@ async function uploadArtifact(client, contributionId, filename, mimeType, buffer
17546
17856
  }
17547
17857
 
17548
17858
  // package.json
17549
- var version = "0.9.5";
17859
+ var version = "0.9.6";
17550
17860
 
17551
17861
  // src/version.ts
17552
17862
  var CLI_VERSION = version;
@@ -19488,6 +19798,53 @@ function freezeEpochBaseline(params) {
19488
19798
  return null;
19489
19799
  }
19490
19800
  }
19801
+ function createBundleWithBackoff(pending, params) {
19802
+ const { repoRoot, baselineTreeSha } = params;
19803
+ const previous = pending?.record.bundleTimeout?.input === baselineTreeSha ? pending.record.bundleTimeout : void 0;
19804
+ if (pending && previous) {
19805
+ const retryInMs = bundleBackoffRemainingMs(previous, Date.now());
19806
+ if (retryInMs > 0) {
19807
+ appendLog(
19808
+ "warn",
19809
+ `git-traces: baseline bundle deferred after timeout (repo=${repoRoot}, session=${pending.record.sessionId}, capture=${pending.record.id}, attempts=${previous.attempts}, retryInMs=${retryInMs})`
19810
+ );
19811
+ return null;
19812
+ }
19813
+ }
19814
+ try {
19815
+ const bundle = createBundleFromTree(
19816
+ repoRoot,
19817
+ baselineTreeSha,
19818
+ params.bundleKey,
19819
+ params.label
19820
+ );
19821
+ if (pending?.record.bundleTimeout) {
19822
+ delete pending.record.bundleTimeout;
19823
+ saveBundleBackoff(pending);
19824
+ }
19825
+ return bundle;
19826
+ } catch (err) {
19827
+ if (pending && isGitTimeoutError(err)) {
19828
+ pending.record.bundleTimeout = {
19829
+ input: baselineTreeSha,
19830
+ attempts: (previous?.attempts ?? 0) + 1,
19831
+ lastAttemptAt: Date.now()
19832
+ };
19833
+ saveBundleBackoff(pending);
19834
+ }
19835
+ throw err;
19836
+ }
19837
+ }
19838
+ function saveBundleBackoff(pending) {
19839
+ try {
19840
+ pending.save();
19841
+ } catch (err) {
19842
+ appendLog(
19843
+ "warn",
19844
+ `git-traces: could not save baseline bundle backoff (capture=${pending.record.id}): ${formatError(err)}`
19845
+ );
19846
+ }
19847
+ }
19491
19848
  function buildFrozenEpochBaselineArtifacts(params) {
19492
19849
  const { repoRoot, sessionId, epoch, baselineTreeSha, baselineMetadata } = params;
19493
19850
  const prefix = epochPrefix2(epoch);
@@ -19497,12 +19854,13 @@ function buildFrozenEpochBaselineArtifacts(params) {
19497
19854
  pending,
19498
19855
  `${prefix}-baseline.bundle`,
19499
19856
  baselineTreeSha
19500
- )) ?? createBundleFromTree(
19857
+ )) ?? createBundleWithBackoff(pending, {
19501
19858
  repoRoot,
19502
19859
  baselineTreeSha,
19503
- `${sessionId}-${prefix}`,
19504
- `baseline ${prefix}`
19505
- );
19860
+ bundleKey: `${sessionId}-${prefix}`,
19861
+ label: `baseline ${prefix}`
19862
+ });
19863
+ if (!bundleBuffer) return null;
19506
19864
  const metadataBuffer = Buffer.from(
19507
19865
  JSON.stringify(baselineMetadata, null, 2)
19508
19866
  );
@@ -21407,6 +21765,10 @@ async function sweepStrandedCaptures(params) {
21407
21765
  tally[state.reason]++;
21408
21766
  continue;
21409
21767
  }
21768
+ if (bundleBackoffRemainingMs(head.bundleTimeout, now) > 0) {
21769
+ tally.backoff++;
21770
+ continue;
21771
+ }
21410
21772
  eligible.push({
21411
21773
  queue,
21412
21774
  oldestCapturedAt,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hillclimb",
3
- "version": "0.9.5",
3
+ "version": "0.9.6",
4
4
  "description": "Extract and export AI coding tool logs grouped by repo",
5
5
  "license": "MIT",
6
6
  "type": "module",