mbeditor 0.3.8 → 0.4.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.
@@ -15,7 +15,6 @@ module Mbeditor
15
15
  IMAGE_EXTENSIONS = %w[png jpg jpeg gif svg ico webp bmp avif].freeze
16
16
  MAX_OPEN_FILE_SIZE_BYTES = 5 * 1024 * 1024
17
17
  RG_AVAILABLE = system("which rg > /dev/null 2>&1")
18
- RUBOCOP_TIMEOUT_SECONDS = 15
19
18
 
20
19
  # GET /mbeditor — renders the IDE shell
21
20
  def index
@@ -59,14 +58,27 @@ module Mbeditor
59
58
  end
60
59
  rescue Errno::ENOENT
61
60
  render json: {}
61
+ rescue JSON::ParserError
62
+ render json: {}
62
63
  rescue StandardError => e
63
64
  render json: { error: e.message }, status: :unprocessable_content
64
65
  end
65
66
 
67
+ STATE_MAX_BYTES = 1 * 1024 * 1024
68
+
66
69
  # POST /mbeditor/state — save workspace state
67
70
  def save_state
71
+ payload = params[:state].to_json
72
+ return render json: { error: "State payload too large" }, status: :content_too_large if payload.bytesize > STATE_MAX_BYTES
73
+
68
74
  path = workspace_root.join("tmp", "mbeditor_workspace.json")
69
- File.write(path, params[:state].to_json)
75
+ FileUtils.mkdir_p(workspace_root.join("tmp"))
76
+ File.open(path, File::RDWR | File::CREAT) do |f|
77
+ f.flock(File::LOCK_EX)
78
+ f.truncate(0)
79
+ f.rewind
80
+ f.write(payload)
81
+ end
70
82
  render json: { ok: true }
71
83
  rescue StandardError => e
72
84
  render json: { error: e.message }, status: :unprocessable_content
@@ -93,11 +105,19 @@ module Mbeditor
93
105
  branch = sanitize_branch_name(params[:branch])
94
106
  return render json: { error: "Invalid branch name" }, status: :bad_request unless branch
95
107
 
108
+ payload = params[:state].to_unsafe_h
109
+ return render json: { error: "State payload too large" }, status: :content_too_large if payload.to_json.bytesize > STATE_MAX_BYTES
110
+
96
111
  path = workspace_root.join("tmp", "mbeditor_branch_states.json")
97
112
  FileUtils.mkdir_p(workspace_root.join("tmp"))
98
- all = File.exist?(path) ? JSON.parse(File.read(path)) : {}
99
- all[branch] = params[:state].to_unsafe_h
100
- File.write(path, all.to_json)
113
+ File.open(path, File::RDWR | File::CREAT) do |f|
114
+ f.flock(File::LOCK_EX)
115
+ existing = f.size > 0 ? JSON.parse(f.read) : {}
116
+ existing[branch] = payload
117
+ f.truncate(0)
118
+ f.rewind
119
+ f.write(existing.to_json)
120
+ end
101
121
  render json: { ok: true }
102
122
  rescue StandardError => e
103
123
  render json: { error: e.message }, status: :unprocessable_content
@@ -113,10 +133,18 @@ module Mbeditor
113
133
  return render json: { pruned: [] } unless status.success?
114
134
 
115
135
  local_branches = out.split("\n").map(&:strip).reject(&:empty?)
116
- all = JSON.parse(File.read(state_path))
117
- pruned = all.keys - local_branches
118
- pruned.each { |b| all.delete(b) }
119
- File.write(state_path, all.to_json)
136
+ pruned = []
137
+ File.open(state_path, File::RDWR) do |f|
138
+ f.flock(File::LOCK_EX)
139
+ all = JSON.parse(f.read) rescue {}
140
+ pruned = all.keys - local_branches
141
+ if pruned.any?
142
+ pruned.each { |b| all.delete(b) }
143
+ f.truncate(0)
144
+ f.rewind
145
+ f.write(all.to_json)
146
+ end
147
+ end
120
148
  render json: { pruned: pruned }
121
149
  rescue StandardError => e
122
150
  render json: { error: e.message }, status: :unprocessable_content
@@ -294,9 +322,20 @@ module Mbeditor
294
322
  return render json: [] if query.blank?
295
323
  return render json: { error: "Query too long" }, status: :bad_request if query.length > 500
296
324
 
297
- results = stream_search_results(query, needed)
298
- has_more = results.length > offset + limit
299
- render json: { results: results[offset, limit] || [], has_more: has_more }
325
+ # On first page, count total matches in parallel with fetching results.
326
+ count_thread = offset == 0 ? Thread.new { count_search_results(query) } : nil
327
+
328
+ results = stream_search_results(query, needed)
329
+ has_more = results.length > offset + limit
330
+ response = { results: results[offset, limit] || [], has_more: has_more }
331
+ if count_thread
332
+ # Give the count thread up to 100 ms; omit total_count when it hasn't finished yet
333
+ # so the first page is never blocked by the counting subprocess.
334
+ count_thread.join(0.1)
335
+ response[:total_count] = count_thread.value unless count_thread.alive?
336
+ end
337
+
338
+ render json: response
300
339
  rescue StandardError => e
301
340
  render json: { error: e.message }, status: :unprocessable_content
302
341
  end
@@ -305,9 +344,7 @@ module Mbeditor
305
344
  def git_status
306
345
  output, _err, status = Open3.capture3("git", "-C", workspace_root.to_s, "status", "--porcelain")
307
346
  branch = GitService.current_branch(workspace_root.to_s) || ""
308
- files = output.lines.map do |line|
309
- { status: line[0..1].strip, path: line[3..].strip }
310
- end
347
+ files = parse_porcelain_status(output)
311
348
  render json: { ok: status.success?, files: files, branch: branch }
312
349
  rescue StandardError => e
313
350
  render json: { error: e.message }, status: :unprocessable_content
@@ -325,7 +362,7 @@ module Mbeditor
325
362
 
326
363
  # Annotate each working-tree file with added/removed line counts
327
364
  numstat_out, = Open3.capture3("git", "-C", repo, "diff", "--numstat", "HEAD")
328
- numstat_map = parse_numstat(numstat_out)
365
+ numstat_map = GitService.parse_numstat(numstat_out)
329
366
  working_tree = working_tree.map { |f| f.merge(numstat_map.fetch(f[:path], {})) }
330
367
 
331
368
  upstream_output, _err, upstream_status = Open3.capture3("git", "-C", repo, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
@@ -339,7 +376,7 @@ module Mbeditor
339
376
 
340
377
  # Determine the branch's fork point relative to a base branch (develop/main/master).
341
378
  # This ensures History and Changes only show work unique to this branch.
342
- base_sha, base_ref = find_branch_base(repo, branch)
379
+ base_sha, base_ref = GitService.find_branch_base(repo, branch)
343
380
 
344
381
  if upstream_branch.present?
345
382
  counts_output, _err, counts_status = Open3.capture3("git", "-C", repo, "rev-list", "--left-right", "--count", "HEAD...#{upstream_branch}")
@@ -350,7 +387,7 @@ module Mbeditor
350
387
  end
351
388
 
352
389
  unpushed_log_output, _err, unpushed_log_status = Open3.capture3("git", "-C", repo, "log", "#{upstream_branch}..HEAD", "--pretty=format:%H%x1f%s%x1f%an%x1f%aI%x1e")
353
- unpushed_commits = parse_git_log(unpushed_log_output) if unpushed_log_status.success?
390
+ unpushed_commits = GitService.parse_git_log(unpushed_log_output) if unpushed_log_status.success?
354
391
  end
355
392
 
356
393
  # "Changes in Branch" — use the merge-base against the base branch when available
@@ -361,7 +398,7 @@ module Mbeditor
361
398
  if unpushed_status.success?
362
399
  unpushed_files = parse_name_status(unpushed_output)
363
400
  unp_numstat_out, = Open3.capture3("git", "-C", repo, "diff", "--numstat", "#{diff_base}..HEAD")
364
- unp_numstat_map = parse_numstat(unp_numstat_out)
401
+ unp_numstat_map = GitService.parse_numstat(unp_numstat_out)
365
402
  unpushed_files = unpushed_files.map { |f| f.merge(unp_numstat_map.fetch(f[:path], {})) }
366
403
  end
367
404
  end
@@ -373,7 +410,7 @@ module Mbeditor
373
410
  Open3.capture3("git", "-C", repo, "log", "--first-parent", branch, "-n", "100",
374
411
  "--pretty=format:%H%x1f%s%x1f%an%x1f%aI%x1e")
375
412
  end
376
- branch_commits = branch_log_status.success? ? parse_git_log(branch_log_output) : []
413
+ branch_commits = branch_log_status.success? ? GitService.parse_git_log(branch_log_output) : []
377
414
 
378
415
  redmine_ticket_id = nil
379
416
  if Mbeditor.configuration.redmine_enabled
@@ -382,7 +419,7 @@ module Mbeditor
382
419
  redmine_ticket_id = m[1] if m
383
420
  else
384
421
  branch_commits.each do |commit|
385
- m = commit[:title]&.match(/#(\d+)/)
422
+ m = commit["title"]&.match(/#(\d+)/)
386
423
  if m
387
424
  redmine_ticket_id = m[1]
388
425
  break
@@ -545,9 +582,10 @@ module Mbeditor
545
582
 
546
583
  # Use a workspace-local tempfile so RuboCop's config discovery walks up
547
584
  # from the source file's directory and finds the host app's .rubocop.yml.
548
- tmpfile = File.join(File.dirname(path), ".mbeditor_fix_#{SecureRandom.hex(8)}#{ext}")
549
- begin
550
- File.write(tmpfile, code)
585
+ Tempfile.create([".mbeditor_fix_", ext], File.dirname(path)) do |f|
586
+ f.write(code)
587
+ f.flush
588
+ tmpfile = f.path
551
589
 
552
590
  cmd = rubocop_command + ["--no-server", "--cache", "false", "-A", "--no-color", tmpfile]
553
591
  env = { 'RUBOCOP_CACHE_ROOT' => File.join(Dir.tmpdir, 'rubocop') }
@@ -561,8 +599,6 @@ module Mbeditor
561
599
  corrected = File.read(tmpfile, encoding: "UTF-8", invalid: :replace, undef: :replace)
562
600
  fix = compute_text_edit(code, corrected)
563
601
  render json: { fix: fix }
564
- ensure
565
- File.delete(tmpfile) if tmpfile && File.exist?(tmpfile)
566
602
  end
567
603
  rescue StandardError => e
568
604
  render json: { error: e.message }, status: :unprocessable_content
@@ -608,10 +644,12 @@ module Mbeditor
608
644
  code = params[:code].to_s
609
645
  return render json: { error: "code required" }, status: :unprocessable_content if code.empty?
610
646
 
611
- ext = File.extname(File.basename(path))
612
- tmpfile = File.join(File.dirname(path), ".mbeditor_fmt_#{SecureRandom.hex(8)}#{ext}")
613
- begin
614
- File.write(tmpfile, code)
647
+ ext = File.extname(File.basename(path))
648
+ Tempfile.create([".mbeditor_fmt_", ext], File.dirname(path)) do |f|
649
+ f.write(code)
650
+ f.flush
651
+ tmpfile = f.path
652
+
615
653
  cmd = rubocop_command + ["--no-server", "--cache", "false", "-A", "--no-color", tmpfile]
616
654
  env = { 'RUBOCOP_CACHE_ROOT' => File.join(Dir.tmpdir, 'rubocop') }
617
655
  _out, _err, status = Open3.capture3(env, *cmd)
@@ -621,8 +659,6 @@ module Mbeditor
621
659
 
622
660
  corrected = File.read(tmpfile, encoding: "UTF-8", invalid: :replace, undef: :replace)
623
661
  render json: { ok: true, content: corrected }
624
- ensure
625
- File.delete(tmpfile) if tmpfile && File.exist?(tmpfile)
626
662
  end
627
663
  rescue StandardError => e
628
664
  render json: { error: e.message }, status: :unprocessable_content
@@ -679,7 +715,8 @@ module Mbeditor
679
715
 
680
716
  begin
681
717
  data = JSON.parse(raw)
682
- rescue JSON::ParserError
718
+ rescue JSON::ParserError => e
719
+ Rails.logger.warn("[mbeditor] search: malformed rg JSON line: #{e.message}")
683
720
  next
684
721
  end
685
722
  next unless data["type"] == "match"
@@ -694,7 +731,7 @@ module Mbeditor
694
731
  end
695
732
  else
696
733
  args = ["grep", "-rn", "-F"]
697
- excluded_dirnames.each { |d| args << "--exclude-dir=#{d}" }
734
+ excluded_dirnames.select { |d| d.match?(/\A[\w.\/-]+\z/) }.each { |d| args << "--exclude-dir=#{d}" }
698
735
  args += [query, workspace_root.to_s]
699
736
 
700
737
  IO.popen(args, err: File::NULL) do |io|
@@ -722,6 +759,30 @@ module Mbeditor
722
759
  results
723
760
  end
724
761
 
762
+ # Count total matching lines across the workspace using rg --count (or grep -c).
763
+ # Fast: rg just counts without extracting context. Runs in a background thread.
764
+ def count_search_results(query)
765
+ total = 0
766
+ if RG_AVAILABLE
767
+ args = ["rg", "--count", "--no-ignore"]
768
+ excluded_paths.each { |p| args << "--glob=!#{p}" }
769
+ args += ["--", query, workspace_root.to_s]
770
+ IO.popen(args, err: File::NULL) do |io|
771
+ io.each_line { |line| total += line.strip.split(":").last.to_i rescue 0 }
772
+ end
773
+ else
774
+ args = ["grep", "-rc", "-F"]
775
+ excluded_dirnames.each { |d| args << "--exclude-dir=#{d}" }
776
+ args += [query, workspace_root.to_s]
777
+ IO.popen(args, err: File::NULL) do |io|
778
+ io.each_line { |line| total += line.strip.split(":").last.to_i rescue 0 }
779
+ end
780
+ end
781
+ total
782
+ rescue StandardError
783
+ 0
784
+ end
785
+
725
786
  def build_tree(dir, max_depth: 10, depth: 0)
726
787
  return [] if depth >= max_depth
727
788
 
@@ -761,27 +822,29 @@ module Mbeditor
761
822
  end
762
823
 
763
824
  def run_with_timeout(env, cmd, stdin_data:)
764
- output = +""
765
- timed_out = false
825
+ timeout_seconds = Mbeditor.configuration.lint_timeout&.to_i
826
+ output = +""; timed_out = false
766
827
 
767
828
  Open3.popen3(env, *cmd, pgroup: true) do |stdin, stdout, _stderr, wait_thr|
768
829
  stdin.write(stdin_data)
769
830
  stdin.close
770
831
 
771
- timer = Thread.new do
772
- sleep RUBOCOP_TIMEOUT_SECONDS
773
- timed_out = true
774
- Process.kill('-KILL', wait_thr.pid)
775
- rescue Errno::ESRCH
776
- nil
832
+ timer = if timeout_seconds && timeout_seconds > 0
833
+ Thread.new do
834
+ sleep timeout_seconds
835
+ timed_out = true
836
+ Process.kill('-KILL', wait_thr.pid)
837
+ rescue Errno::ESRCH
838
+ nil
839
+ end
777
840
  end
778
841
 
779
842
  output = stdout.read
780
843
  wait_thr.value
781
- timer.kill
844
+ timer&.kill
782
845
  end
783
846
 
784
- raise "RuboCop timed out after #{RUBOCOP_TIMEOUT_SECONDS} seconds" if timed_out
847
+ raise "RuboCop timed out after #{timeout_seconds} seconds" if timed_out
785
848
 
786
849
  output
787
850
  end
@@ -856,48 +919,46 @@ module Mbeditor
856
919
  candidate.exist? ? ".rubocop.yml" : nil
857
920
  end
858
921
 
922
+ PROBE_MUTEX = Mutex.new
923
+ private_constant :PROBE_MUTEX
924
+
859
925
  def rubocop_available?
860
- # Key on the configured rubocop command so the cache is invalidated if the
861
- # command changes (e.g., between test cases or after reconfiguration).
862
- key = Mbeditor.configuration.rubocop_command.to_s
863
- cache = self.class.instance_variable_get(:@rubocop_available_cache) ||
864
- self.class.instance_variable_set(:@rubocop_available_cache, {})
865
- return cache[key] if cache.key?(key)
866
- cache[key] = begin
926
+ key = Mbeditor.configuration.rubocop_command.to_s
927
+ probe_cached(:@rubocop_available_cache, key) do
867
928
  _out, _err, status = Open3.capture3(*rubocop_command, "--version")
868
929
  status.success?
869
- rescue StandardError
870
- false
871
930
  end
872
931
  end
873
932
 
874
933
  def haml_lint_available?
875
- # Key on the resolved command array so workspace-local bin/haml-lint is
876
- # respected without re-running the probe on every request.
877
- cmd = haml_lint_command
878
- key = cmd.join(" ")
879
- cache = self.class.instance_variable_get(:@haml_lint_available_cache) ||
880
- self.class.instance_variable_set(:@haml_lint_available_cache, {})
881
- return cache[key] if cache.key?(key)
882
- cache[key] = begin
934
+ cmd = haml_lint_command
935
+ key = cmd.join(" ")
936
+ probe_cached(:@haml_lint_available_cache, key) do
883
937
  _out, _err, status = Open3.capture3(*cmd, "--version")
884
938
  status.success?
885
- rescue StandardError
886
- false
887
939
  end
888
940
  end
889
941
 
890
942
  def git_available?
891
- # Key on workspace path so different directories get their own probe result.
892
- key = workspace_root.to_s
893
- cache = self.class.instance_variable_get(:@git_available_cache) ||
894
- self.class.instance_variable_set(:@git_available_cache, {})
895
- return cache[key] if cache.key?(key)
896
- cache[key] = begin
943
+ key = workspace_root.to_s
944
+ probe_cached(:@git_available_cache, key) do
897
945
  _out, _err, status = Open3.capture3("git", "-C", key, "rev-parse", "--is-inside-work-tree")
898
946
  status.success?
899
- rescue StandardError
900
- false
947
+ end
948
+ end
949
+
950
+ def probe_cached(ivar, key, &block)
951
+ PROBE_MUTEX.synchronize do
952
+ cache = self.class.instance_variable_get(ivar) ||
953
+ self.class.instance_variable_set(ivar, {})
954
+ unless cache.key?(key)
955
+ cache[key] = begin
956
+ block.call
957
+ rescue StandardError
958
+ false
959
+ end
960
+ end
961
+ cache[key]
901
962
  end
902
963
  end
903
964
 
@@ -952,8 +1013,13 @@ module Mbeditor
952
1013
  end
953
1014
 
954
1015
  def parse_porcelain_status(output)
955
- output.lines.map do |line|
956
- { status: line[0..1].strip, path: line[3..].to_s.strip }
1016
+ output.lines.filter_map do |line|
1017
+ next if line.length < 4
1018
+
1019
+ path = line[3..].to_s.strip
1020
+ next if path.blank?
1021
+
1022
+ { status: line[0..1].strip, path: path }
957
1023
  end
958
1024
  end
959
1025
 
@@ -970,29 +1036,6 @@ module Mbeditor
970
1036
  end
971
1037
  end
972
1038
 
973
- def parse_numstat(output)
974
- (output || "").lines.each_with_object({}) do |line, map|
975
- parts = line.strip.split("\t", 3)
976
- next if parts.length < 3 || parts[0] == "-"
977
-
978
- map[parts[2].strip] = { added: parts[0].to_i, removed: parts[1].to_i }
979
- end
980
- end
981
-
982
- def parse_git_log(output)
983
- output.split("\x1e").filter_map do |entry|
984
- fields = entry.strip.split("\x1f", 4)
985
- next unless fields.length == 4
986
-
987
- {
988
- hash: fields[0],
989
- title: fields[1],
990
- author: fields[2],
991
- date: fields[3]
992
- }
993
- end
994
- end
995
-
996
1039
  def monaco_worker_file
997
1040
  engine_path = Mbeditor::Engine.root.join("public", "monaco_worker.js")
998
1041
  return engine_path if engine_path.file?
@@ -1000,38 +1043,6 @@ module Mbeditor
1000
1043
  Rails.root.join("public", "monaco_worker.js")
1001
1044
  end
1002
1045
 
1003
- # Returns [merge_base_sha, ref_name] of the first candidate base branch found,
1004
- # or [nil, nil] if none can be determined. Candidates are tried in preference order:
1005
- # origin/develop → origin/main → origin/master → develop → main → master.
1006
- # Skips refs that ARE the current branch and refs where the merge-base equals HEAD
1007
- # (meaning the current branch is behind or at the same point as that ref).
1008
- def find_branch_base(repo, current_branch)
1009
- candidates = %w[origin/develop origin/main origin/master develop main master]
1010
- head_sha_out, = Open3.capture3("git", "-C", repo, "rev-parse", "HEAD")
1011
- head_sha = head_sha_out.strip
1012
-
1013
- candidates.each do |ref|
1014
- short = ref.delete_prefix("origin/")
1015
- next if short == current_branch || ref == current_branch
1016
-
1017
- _o, _e, st = Open3.capture3("git", "-C", repo, "rev-parse", "--verify", "--quiet", ref)
1018
- next unless st.success?
1019
-
1020
- base_out, _e, base_st = Open3.capture3("git", "-C", repo, "merge-base", "HEAD", ref)
1021
- next unless base_st.success?
1022
-
1023
- sha = base_out.strip
1024
- next unless sha.match?(/\A[0-9a-f]{40}\z/)
1025
- next if sha == head_sha # branch is at/behind this ref — no unique commits
1026
-
1027
- return [sha, ref]
1028
- end
1029
-
1030
- [nil, nil]
1031
- rescue StandardError
1032
- [nil, nil]
1033
- end
1034
-
1035
1046
  def resolve_monaco_asset_path(asset_path)
1036
1047
  return nil if asset_path.blank?
1037
1048
 
@@ -26,7 +26,8 @@ module Mbeditor
26
26
  head = nil if head == 'WORKING'
27
27
  # Allow full/short SHA hashes plus common git ref formats: branch names,
28
28
  # HEAD, remote tracking refs, parent notation (sha^, sha~N) and tags.
29
- valid_ref = /\A[a-zA-Z0-9._\-\/\^~@]+\z/
29
+ # @ is excluded to block reflog syntax like @{-1} or HEAD@{2}.
30
+ valid_ref = /\A[a-zA-Z0-9._\-\/\^~]+\z/
30
31
  if [base, head].any? { |s| s && (s.length > 200 || !s.match?(valid_ref)) }
31
32
  return render json: { error: 'Invalid ref' }, status: :bad_request
32
33
  end
@@ -88,15 +89,7 @@ module Mbeditor
88
89
  "diff-tree", "--no-commit-id", "-r", "--numstat", sha
89
90
  )
90
91
 
91
- numstat_map = {}
92
- if numstat_status.success?
93
- numstat_output.lines.each do |line|
94
- parts = line.strip.split("\t", 3)
95
- next if parts.length < 3 || parts[0] == "-"
96
-
97
- numstat_map[parts[2].strip] = { "added" => parts[0].to_i, "removed" => parts[1].to_i }
98
- end
99
- end
92
+ numstat_map = numstat_status.success? ? GitService.parse_numstat(numstat_output) : {}
100
93
 
101
94
  files = []
102
95
  if files_status.success?
@@ -136,7 +129,7 @@ module Mbeditor
136
129
  else
137
130
  repo = workspace_root.to_s
138
131
  branch = GitService.current_branch(repo)
139
- base_sha, = find_branch_base(repo, branch)
132
+ base_sha, = GitService.find_branch_base(repo, branch)
140
133
 
141
134
  if base_sha.present?
142
135
  out, _err, status = Open3.capture3("git", "-C", repo, "diff", "#{base_sha}..HEAD")
@@ -153,7 +146,7 @@ module Mbeditor
153
146
  out, _err, status = Open3.capture3("git", "-C", repo, "diff", "#{upstream}..HEAD")
154
147
  out = status.success? ? out : ""
155
148
  else
156
- out = ""
149
+ return render plain: "", content_type: "text/plain"
157
150
  end
158
151
  end
159
152
  end
@@ -202,33 +195,5 @@ module Mbeditor
202
195
  relative_path(full)
203
196
  end
204
197
 
205
- # Returns [merge_base_sha, ref_name] of the first candidate base branch found,
206
- # or [nil, nil] if none can be determined.
207
- def find_branch_base(repo, current_branch)
208
- candidates = %w[origin/develop origin/main origin/master develop main master]
209
- head_sha_out, = Open3.capture3("git", "-C", repo, "rev-parse", "HEAD")
210
- head_sha = head_sha_out.strip
211
-
212
- candidates.each do |ref|
213
- short = ref.delete_prefix("origin/")
214
- next if short == current_branch || ref == current_branch
215
-
216
- _o, _e, st = Open3.capture3("git", "-C", repo, "rev-parse", "--verify", "--quiet", ref)
217
- next unless st.success?
218
-
219
- base_out, _e, base_st = Open3.capture3("git", "-C", repo, "merge-base", "HEAD", ref)
220
- next unless base_st.success?
221
-
222
- sha = base_out.strip
223
- next unless sha.match?(/\A[0-9a-f]{40}\z/)
224
- next if sha == head_sha
225
-
226
- return [sha, ref]
227
- end
228
-
229
- [nil, nil]
230
- rescue StandardError
231
- [nil, nil]
232
- end
233
198
  end
234
199
  end
@@ -92,6 +92,12 @@ module Mbeditor
92
92
  end
93
93
  end
94
94
 
95
+ # Guard against a truncated final block (no TAB content line emitted).
96
+ if current["sha"] && !current.key?("content")
97
+ current["content"] = ""
98
+ results << current.dup
99
+ end
100
+
95
101
  results
96
102
  end
97
103
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "set"
4
+
3
5
  module Mbeditor
4
6
  # Builds commit graph data for rendering a VSCode-style commit graph.
5
7
  #