bitfab 0.65.4 → 0.65.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fd7bdd7dbd520e06041d5250b0b291f90b3a22d0b92ed6c43bc29b2d869f6135
4
- data.tar.gz: b2a722780ca027f97c46433e6b2bba6db6223bc70122dba24e141a5b868a3c81
3
+ metadata.gz: 614f7adf31df970f2961c5ce1bd647d0e875e7e0324e3cad4279846b6a0a2393
4
+ data.tar.gz: 9dff87903a5c97d9cf0ea9cc705840b6015c21ee0a03d2f89943a76e694d4226
5
5
  SHA512:
6
- metadata.gz: f79740fe5b1fcae3ffb8d5926b4ecb7852988dcd36671c8b18486480b1de8b818c96f36621b2606dfc2c057b12dd7718fdaf5efa026b2102dac4cd92c9dd3e4e
7
- data.tar.gz: 6e71b00586519e4edf32ee3b8c984adfcf3fa39e77b23a1dc79c6890419a3d0dda12dd9bce72bac02645fe3281e618b38d7960a9e531e09b54305f1dea9e5ed9
6
+ metadata.gz: a49875ee7ef5769bd78e1a6faf8095fc25c67aa2edb54dfc945c33dc26788881182204ccc21f9799c060fbba3c6f582293863f459beb3a0eab476fde47ee7979
7
+ data.tar.gz: f78dc742e2b597b173a7270d9c49486bbccff14f1439e54f6d9406b67c78fcf88838125ce8d480065f54ccf9d5783381455204ef96b50ab1bc5ffdaf38d39580
data/lib/bitfab/client.rb CHANGED
@@ -58,7 +58,8 @@ module Bitfab
58
58
  @resolved_api_key = nil
59
59
  @api_key_warned = false
60
60
  @http_client = HttpClient.new(api_key: -> { resolve_api_key }, service_url: @service_url, timeout:)
61
- @http_client.configure_simulation_plan(enabled: simulation_plan, start: !api_key_resolved_lazily?)
61
+ @http_client.configure_simulation_plan(enabled: simulation_plan, start: !api_key_resolved_lazily?,
62
+ can_read: -> { simulation_plan_readable? })
62
63
  @datasets = Datasets.new(@http_client)
63
64
  @categories = Categories.new(@http_client)
64
65
  @organization_members = OrganizationMembers.new(@http_client)
@@ -853,6 +854,13 @@ module Bitfab
853
854
  !resolve_api_key.nil?
854
855
  end
855
856
 
857
+ def simulation_plan_readable?
858
+ recording = capture_configured? || ReplayContext.current || SeedContext.current
859
+ recording && !lookup_api_key.nil?
860
+ rescue
861
+ false
862
+ end
863
+
856
864
  def tracing_enabled?
857
865
  return false unless capture_configured?
858
866
 
@@ -154,6 +154,7 @@ CODE_CHANGE_PATH_ENV = "BITFAB_CODE_CHANGE_PATH"
154
154
  CODE_CHANGE_MAX_FILES = 60
155
155
  CODE_CHANGE_MAX_FILE_BYTES = 500_000
156
156
  CODE_CHANGE_MAX_TOTAL_BYTES = 2_000_000
157
+ BRANCH_WALK_LIMIT = 1000
157
158
  CHECK_ARGUMENTS_SECONDS = 300
158
159
  CHECK_ARGUMENTS_FLAG = "--check-arguments"
159
160
  REPLAY_ARGUMENTS_FLAG = "--replay-arguments"
@@ -933,6 +934,98 @@ def current_branch(root):
933
934
  return None
934
935
 
935
936
 
937
+ def quiet_git(root, *args):
938
+ try:
939
+ return git(root, *args)
940
+ except CommandError:
941
+ return ""
942
+
943
+
944
+ def default_branch(root):
945
+ origin_head = quiet_git(root, "rev-parse", "--abbrev-ref", "origin/HEAD")
946
+ candidates = [
947
+ origin_head if origin_head and origin_head != "origin/HEAD" else None,
948
+ "origin/main",
949
+ "origin/master",
950
+ "main",
951
+ "master",
952
+ ]
953
+ return next(
954
+ (
955
+ ref
956
+ for ref in dict.fromkeys(filter(None, candidates))
957
+ if quiet_git(root, "rev-parse", "--verify", "-q", f"{ref}^{{commit}}")
958
+ ),
959
+ None,
960
+ )
961
+
962
+
963
+ def current_branch_refs(root):
964
+ branch = current_branch(root)
965
+ if not branch:
966
+ return set()
967
+ return {f"refs/heads/{branch}"} | {
968
+ f"refs/remotes/{remote}/{branch}"
969
+ for remote in quiet_git(root, "remote").split()
970
+ }
971
+
972
+
973
+ def own_branch_refs(root):
974
+ upstream = quiet_git(root, "rev-parse", "--symbolic-full-name", "@{upstream}")
975
+ own = current_branch_refs(root)
976
+ if upstream:
977
+ own.add(upstream)
978
+ return own
979
+
980
+
981
+ def on_default_branch(root, default):
982
+ full_name = quiet_git(root, "rev-parse", "--symbolic-full-name", default)
983
+ return bool(full_name) and full_name in current_branch_refs(root)
984
+
985
+
986
+ def branch_tips(root):
987
+ own = own_branch_refs(root)
988
+ listing = quiet_git(
989
+ root,
990
+ "for-each-ref",
991
+ "--format=%(objectname) %(refname)",
992
+ "refs/heads",
993
+ "refs/remotes",
994
+ )
995
+ tips = {}
996
+ for line in listing.splitlines():
997
+ sha, _, ref = line.partition(" ")
998
+ if ref and ref not in own and not ref.endswith("/HEAD"):
999
+ tips.setdefault(
1000
+ sha, ref.removeprefix("refs/heads/").removeprefix("refs/remotes/")
1001
+ )
1002
+ return tips
1003
+
1004
+
1005
+ def branch_point(root):
1006
+ head = quiet_git(root, "rev-parse", "HEAD")
1007
+ if not head:
1008
+ return None
1009
+ default = default_branch(root)
1010
+ if default and on_default_branch(root, default):
1011
+ return None
1012
+ default_base = quiet_git(root, "merge-base", "HEAD", default) if default else ""
1013
+ walk = ["rev-list", "--first-parent", f"--max-count={BRANCH_WALK_LIMIT}", "HEAD"]
1014
+ if default_base:
1015
+ walk.append(f"^{default_base}")
1016
+ tips = branch_tips(root)
1017
+ for sha in quiet_git(root, *walk).split()[1:]:
1018
+ if sha in tips:
1019
+ return {"sha": sha, "ref": tips[sha]}
1020
+ if default_base and default_base != head:
1021
+ return {"sha": default_base, "ref": default}
1022
+ return None
1023
+
1024
+
1025
+ def replay_artifact(path):
1026
+ return path == ".bitfab" or path.startswith(".bitfab/")
1027
+
1028
+
936
1029
  def sensitive(path):
937
1030
  parts = Path(path).parts
938
1031
  name = Path(path).name.lower()
@@ -1033,12 +1126,20 @@ def snapshot(
1033
1126
  }
1034
1127
  if preview:
1035
1128
  return summary
1129
+ code_change_base = request.get("codeChangeBase") or {}
1130
+ extra_parent = (
1131
+ ["-p", code_change_base["sha"]]
1132
+ if not any(not replay_artifact(path) for _, path in changes)
1133
+ and SHA.fullmatch(str(code_change_base.get("sha", "")))
1134
+ else []
1135
+ )
1036
1136
  sha = git(
1037
1137
  root,
1038
1138
  "commit-tree",
1039
1139
  tree,
1040
1140
  "-p",
1041
1141
  head,
1142
+ *extra_parent,
1042
1143
  input=f"{snapshot_subject(request['id'], on_push)}\n\n{json.dumps(request)}\n",
1043
1144
  )
1044
1145
  return {**summary, "sha": sha}
@@ -1360,6 +1461,9 @@ def submit(root, repo, workflow, parsed, path):
1360
1461
  branch = current_branch(root)
1361
1462
  if branch:
1362
1463
  request["branch"] = branch
1464
+ code_change_base = branch_point(root)
1465
+ if code_change_base:
1466
+ request["codeChangeBase"] = code_change_base
1363
1467
  check_arguments(parsed["replay"])
1364
1468
  allowed = allowed_files(root, parsed)
1365
1469
  if "--cloud-preview" in parsed["switches"]:
@@ -2067,7 +2171,9 @@ def changed_paths(root, base, commit):
2067
2171
  yield status, before_path, path
2068
2172
 
2069
2173
 
2070
- def snapshot_code_change(root, base, commit, name):
2174
+ def snapshot_code_change(
2175
+ root, base, commit, name, against="uncommitted (vs HEAD)", subject_ref=None
2176
+ ):
2071
2177
  files, total = [], 0
2072
2178
  for status, before_path, path in changed_paths(root, base, commit):
2073
2179
  if len(files) >= CODE_CHANGE_MAX_FILES:
@@ -2083,14 +2189,34 @@ def snapshot_code_change(root, base, commit, name):
2083
2189
  files.append({"path": path, "before": before, "after": after})
2084
2190
  if not files:
2085
2191
  return None
2086
- head = (name or "").strip() or git(root, "log", "-1", "--format=%s", base)
2192
+ head = (name or "").strip() or git(
2193
+ root, "log", "-1", "--format=%s", subject_ref or base
2194
+ )
2087
2195
  word = "file" if len(files) == 1 else "files"
2088
2196
  return {
2089
- "description": f"{head or 'Working-tree change'} ({len(files)} {word} changed uncommitted (vs HEAD))",
2197
+ "description": f"{head or 'Working-tree change'} ({len(files)} {word} changed {against})",
2090
2198
  "files": files,
2091
2199
  }
2092
2200
 
2093
2201
 
2202
+ def branch_code_change(root, base, commit, request):
2203
+ code_change_base = request.get("codeChangeBase")
2204
+ if not isinstance(code_change_base, dict):
2205
+ return None
2206
+ sha, ref = code_change_base.get("sha"), code_change_base.get("ref")
2207
+ if not (
2208
+ isinstance(sha, str)
2209
+ and SHA.fullmatch(sha)
2210
+ and isinstance(ref, str)
2211
+ and BRANCH_NAME.fullmatch(ref)
2212
+ and quiet_git(root, "rev-parse", "--verify", "-q", f"{commit}^2") == sha
2213
+ ):
2214
+ return None
2215
+ return snapshot_code_change(
2216
+ root, sha, commit, replay_name(request["args"]), f"vs {ref}", base
2217
+ )
2218
+
2219
+
2094
2220
  def write_code_change(change):
2095
2221
  handle, path = tempfile.mkstemp(
2096
2222
  prefix="bitfab-code-change-",
@@ -2127,6 +2253,7 @@ def execute():
2127
2253
  None
2128
2254
  if explicit_change or base is None
2129
2255
  else snapshot_code_change(root, base, commit, replay_name(request["args"]))
2256
+ or branch_code_change(root, base, commit, request)
2130
2257
  )
2131
2258
  run_environment = dict(environment)
2132
2259
  if change:
@@ -56,8 +56,8 @@ module Bitfab
56
56
  @close_condition = ConditionVariable.new
57
57
  end
58
58
 
59
- def configure_simulation_plan(enabled: true, start: true)
60
- @simulation_plan = SimulationPlan.new(self, enabled:)
59
+ def configure_simulation_plan(enabled: true, start: true, can_read: nil)
60
+ @simulation_plan = SimulationPlan.new(self, enabled:, can_read:)
61
61
  @simulation_plan.refresh if start
62
62
  @simulation_plan
63
63
  end
data/lib/bitfab/replay.rb CHANGED
@@ -598,6 +598,7 @@ module Bitfab
598
598
  CC_MAX_FILES = 60
599
599
  CC_MAX_FILE_BYTES = 500_000
600
600
  CC_MAX_TOTAL_BYTES = 2_000_000
601
+ CC_MAX_BRANCH_WALK = 1000
601
602
 
602
603
  def resolve_skip_assertion_judging(skip_assertion_judging, judge_assertions)
603
604
  return skip_assertion_judging if judge_assertions.nil?
@@ -658,7 +659,9 @@ module Bitfab
658
659
  # in replay() (the one call guaranteed to run on every replay) so it works no
659
660
  # matter how the replay was launched. Precedence: a BITFAB_CODE_CHANGE_PATH
660
661
  # override, else the git diff of the working tree vs HEAD (or vs the
661
- # merge-base with BITFAB_CODE_CHANGE_BASE). Best-effort: any failure yields nil.
662
+ # merge-base with BITFAB_CODE_CHANGE_BASE). A clean working tree falls back to
663
+ # the point where HEAD split from the default branch, so committed branch work
664
+ # still shows. Best-effort: any failure yields nil.
662
665
  # An explicit code_change_files always wins over this.
663
666
  def resolve_auto_code_change(label = nil)
664
667
  return nil if ENV["BITFAB_DISABLE_CODE_CHANGE_CAPTURE"]
@@ -711,6 +714,73 @@ module Bitfab
711
714
  cc_ref_exists?(root, "HEAD") ? ["HEAD", "uncommitted (vs HEAD)"] : nil
712
715
  end
713
716
 
717
+ def cc_list_changes(root, base)
718
+ tracked = git(root, ["diff", "--name-status", "--find-renames", "-z", base, "--", ":!.bitfab"]) || ""
719
+ untracked = git(root, ["ls-files", "--others", "--exclude-standard", "-z", "--", ":!.bitfab"]) || ""
720
+ entries = cc_parse_name_status_z(tracked)
721
+ untracked.split("\0").reject(&:empty?).each { |p| entries << ["A", p, p] }
722
+ entries
723
+ end
724
+
725
+ def cc_default_branch_candidates(root)
726
+ origin_head = (git(root, ["rev-parse", "--abbrev-ref", "origin/HEAD"]) || "").strip
727
+ origin_head = nil if origin_head.empty? || origin_head == "origin/HEAD"
728
+ [origin_head, "origin/main", "origin/master", "main", "master"].compact.uniq
729
+ end
730
+
731
+ def cc_default_branch(root)
732
+ cc_default_branch_candidates(root).find { |ref| cc_ref_exists?(root, ref) }
733
+ end
734
+
735
+ def cc_current_branch_refs(root)
736
+ branch = (git(root, ["symbolic-ref", "--short", "-q", "HEAD"]) || "").strip
737
+ return [] if branch.empty?
738
+
739
+ ["refs/heads/#{branch}"] + (git(root, ["remote"]) || "").split.map { |remote| "refs/remotes/#{remote}/#{branch}" }
740
+ end
741
+
742
+ def cc_own_branch_refs(root)
743
+ upstream = (git(root, ["rev-parse", "--symbolic-full-name", "@{upstream}"]) || "").strip
744
+ own = cc_current_branch_refs(root)
745
+ own << upstream unless upstream.empty?
746
+ own
747
+ end
748
+
749
+ def cc_on_default_branch?(root, default)
750
+ full_name = (git(root, ["rev-parse", "--symbolic-full-name", default]) || "").strip
751
+ !full_name.empty? && cc_current_branch_refs(root).include?(full_name)
752
+ end
753
+
754
+ def cc_branch_tips(root)
755
+ own = cc_own_branch_refs(root)
756
+ listing = git(root, ["for-each-ref", "--format=%(objectname) %(refname)", "refs/heads", "refs/remotes"]) || ""
757
+ listing.each_line.with_object({}) do |line, tips|
758
+ sha, ref = line.strip.split(" ", 2)
759
+ next if ref.nil? || own.include?(ref) || ref.end_with?("/HEAD") || tips.key?(sha)
760
+
761
+ tips[sha] = ref.sub(%r{\Arefs/(heads|remotes)/}, "")
762
+ end
763
+ end
764
+
765
+ def cc_resolve_branch_point(root)
766
+ head = (git(root, ["rev-parse", "HEAD"]) || "").strip
767
+ return nil if head.empty?
768
+
769
+ default = cc_default_branch(root)
770
+ return nil if default && cc_on_default_branch?(root, default)
771
+
772
+ default_base = default ? (git(root, ["merge-base", "HEAD", default]) || "").strip : ""
773
+ walk = ["rev-list", "--first-parent", "--max-count=#{CC_MAX_BRANCH_WALK}", "HEAD"]
774
+ walk << "^#{default_base}" unless default_base.empty?
775
+ tips = cc_branch_tips(root)
776
+ (git(root, walk) || "").split.drop(1).each do |sha|
777
+ return [sha, "vs #{tips[sha]}"] if tips.key?(sha)
778
+ end
779
+ return [default_base, "vs #{default}"] unless default_base.empty? || default_base == head
780
+
781
+ nil
782
+ end
783
+
714
784
  def cc_blob_bytes(root, base, path)
715
785
  out = git(root, ["cat-file", "-s", "#{base}:#{path}"])
716
786
  (out && !out.strip.empty?) ? out.strip.to_i : 0
@@ -732,15 +802,18 @@ module Bitfab
732
802
  resolved = cc_resolve_base(root)
733
803
  return nil if resolved.nil?
734
804
 
735
- base, against = resolved
736
-
737
- tracked = git(root, ["diff", "--name-status", "--find-renames", "-z", base, "--", ":!.bitfab"]) || ""
738
- untracked = git(root, ["ls-files", "--others", "--exclude-standard", "-z", "--", ":!.bitfab"]) || ""
739
-
740
- entries = cc_parse_name_status_z(tracked)
741
- untracked.split("\0").reject(&:empty?).each { |p| entries << ["A", p, p] }
805
+ entries = cc_list_changes(root, resolved[0])
806
+ if entries.empty? && resolved[0] == "HEAD"
807
+ branch_point = cc_resolve_branch_point(root)
808
+ if branch_point
809
+ resolved = branch_point
810
+ entries = cc_list_changes(root, branch_point[0])
811
+ end
812
+ end
742
813
  return nil if entries.empty?
743
814
 
815
+ base, against = resolved
816
+
744
817
  files = []
745
818
  total = 0
746
819
  entries.each do |status, before_path, path|
@@ -42,9 +42,10 @@ module Bitfab
42
42
  end
43
43
  end
44
44
 
45
- def initialize(reader, enabled: true)
45
+ def initialize(reader, enabled: true, can_read: nil)
46
46
  @reader = reader
47
47
  @enabled = enabled
48
+ @can_read = can_read
48
49
  @pid = Process.pid
49
50
  @mutex = Mutex.new
50
51
  @condition = ConditionVariable.new
@@ -56,25 +57,26 @@ module Bitfab
56
57
  @stopped = false
57
58
  @first_read_finished = false
58
59
  @first_read_deadline = nil
60
+ @read_started = false
59
61
  self.class.register(self)
60
62
  end
61
63
 
62
64
  def disabled?
63
- !@enabled || !ENV.fetch("BITFAB_DISABLE_SIM_PLAN", "").strip.empty?
65
+ switched_off? || (!@read_started && !readable?)
64
66
  end
65
67
 
66
68
  def refresh
67
69
  reset_after_fork
68
- return if disabled? || @stopped
70
+ return if disabled?
69
71
 
70
- @mutex.synchronize { start_reader(0) if @thread.nil? && now >= @refresh_after }
72
+ refresh_for_record
71
73
  end
72
74
 
73
75
  def send_span(payload, declared_node: false, discard: nil, &submit)
74
- refresh
76
+ refresh_for_record
75
77
  data = payload.dig("rawSpan", "span_data")
76
78
  key = payload["rootTraceFunctionKey"] || payload["traceFunctionKey"]
77
- if disabled? || key.nil? || !data.is_a?(Hash) || framework?(payload)
79
+ if switched_off? || key.nil? || !data.is_a?(Hash) || framework?(payload)
78
80
  submit.call(payload)
79
81
  return
80
82
  end
@@ -84,11 +86,11 @@ module Bitfab
84
86
  end
85
87
 
86
88
  def send_trace(payload, &submit)
87
- refresh
89
+ refresh_for_record
88
90
  entry = [payload, nil, submit]
89
91
  ready = @mutex.synchronize do
90
92
  id = trace_id(payload)
91
- if !disabled? && !@stopped && id && (@draining.include?(id) || @held.any? { |held| held[1] && trace_id(held[0]) == id })
93
+ if !switched_off? && !@stopped && id && (@draining.include?(id) || @held.any? { |held| held[1] && trace_id(held[0]) == id })
92
94
  hold(entry)
93
95
  else
94
96
  entry
@@ -172,6 +174,21 @@ module Bitfab
172
174
 
173
175
  private
174
176
 
177
+ def refresh_for_record
178
+ reset_after_fork
179
+ return if switched_off? || @stopped
180
+
181
+ @mutex.synchronize { start_reader(0) if @thread.nil? && now >= @refresh_after }
182
+ end
183
+
184
+ def switched_off?
185
+ !@enabled || !ENV.fetch("BITFAB_DISABLE_SIM_PLAN", "").strip.empty?
186
+ end
187
+
188
+ def readable?
189
+ @can_read.nil? || @can_read.call == true
190
+ end
191
+
175
192
  def now
176
193
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
177
194
  end
@@ -194,7 +211,7 @@ module Bitfab
194
211
 
195
212
  def hold(entry)
196
213
  @held << entry
197
- start_reader([@refresh_after - now, 0].max) if @thread.nil? && !@stopped && !disabled?
214
+ start_reader([@refresh_after - now, 0].max) if @thread.nil? && !@stopped && !switched_off?
198
215
  @held.shift if @held.length > MAX_HELD
199
216
  end
200
217
 
@@ -210,7 +227,7 @@ module Bitfab
210
227
  end
211
228
 
212
229
  def prepare(payload, key)
213
- return payload if key.nil? || disabled?
230
+ return payload if key.nil? || switched_off?
214
231
  return apply(payload, key) if @mutex.synchronize { @content_off }
215
232
 
216
233
  without_unread_content(payload)
@@ -232,6 +249,7 @@ module Bitfab
232
249
  end
233
250
 
234
251
  def start_reader(delay)
252
+ @read_started = true
235
253
  @first_read_deadline ||= now + delay + READ_TIMEOUT
236
254
  @thread = Thread.new { read_until_loaded(delay) }
237
255
  @thread.name = "bitfab-sim-plan"
@@ -270,7 +288,7 @@ module Bitfab
270
288
 
271
289
  def read_until_loaded(delay)
272
290
  pause(delay) if delay.positive?
273
- parsed = (disabled? || @stopped) ? nil : read_once
291
+ parsed = (switched_off? || @stopped) ? nil : read_once
274
292
  entries = @mutex.synchronize do
275
293
  @first_read_finished = true
276
294
  @content_off = parsed if parsed
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bitfab
4
- VERSION = "0.65.4"
4
+ VERSION = "0.65.6"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bitfab
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.65.4
4
+ version: 0.65.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Harvest Team