mbeditor 0.3.8 → 0.3.9

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,15 @@ 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
+ response[:total_count] = count_thread.value if count_thread
332
+
333
+ render json: response
300
334
  rescue StandardError => e
301
335
  render json: { error: e.message }, status: :unprocessable_content
302
336
  end
@@ -305,9 +339,7 @@ module Mbeditor
305
339
  def git_status
306
340
  output, _err, status = Open3.capture3("git", "-C", workspace_root.to_s, "status", "--porcelain")
307
341
  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
342
+ files = parse_porcelain_status(output)
311
343
  render json: { ok: status.success?, files: files, branch: branch }
312
344
  rescue StandardError => e
313
345
  render json: { error: e.message }, status: :unprocessable_content
@@ -325,7 +357,7 @@ module Mbeditor
325
357
 
326
358
  # Annotate each working-tree file with added/removed line counts
327
359
  numstat_out, = Open3.capture3("git", "-C", repo, "diff", "--numstat", "HEAD")
328
- numstat_map = parse_numstat(numstat_out)
360
+ numstat_map = GitService.parse_numstat(numstat_out)
329
361
  working_tree = working_tree.map { |f| f.merge(numstat_map.fetch(f[:path], {})) }
330
362
 
331
363
  upstream_output, _err, upstream_status = Open3.capture3("git", "-C", repo, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
@@ -339,7 +371,7 @@ module Mbeditor
339
371
 
340
372
  # Determine the branch's fork point relative to a base branch (develop/main/master).
341
373
  # This ensures History and Changes only show work unique to this branch.
342
- base_sha, base_ref = find_branch_base(repo, branch)
374
+ base_sha, base_ref = GitService.find_branch_base(repo, branch)
343
375
 
344
376
  if upstream_branch.present?
345
377
  counts_output, _err, counts_status = Open3.capture3("git", "-C", repo, "rev-list", "--left-right", "--count", "HEAD...#{upstream_branch}")
@@ -350,7 +382,7 @@ module Mbeditor
350
382
  end
351
383
 
352
384
  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?
385
+ unpushed_commits = GitService.parse_git_log(unpushed_log_output) if unpushed_log_status.success?
354
386
  end
355
387
 
356
388
  # "Changes in Branch" — use the merge-base against the base branch when available
@@ -361,7 +393,7 @@ module Mbeditor
361
393
  if unpushed_status.success?
362
394
  unpushed_files = parse_name_status(unpushed_output)
363
395
  unp_numstat_out, = Open3.capture3("git", "-C", repo, "diff", "--numstat", "#{diff_base}..HEAD")
364
- unp_numstat_map = parse_numstat(unp_numstat_out)
396
+ unp_numstat_map = GitService.parse_numstat(unp_numstat_out)
365
397
  unpushed_files = unpushed_files.map { |f| f.merge(unp_numstat_map.fetch(f[:path], {})) }
366
398
  end
367
399
  end
@@ -373,7 +405,7 @@ module Mbeditor
373
405
  Open3.capture3("git", "-C", repo, "log", "--first-parent", branch, "-n", "100",
374
406
  "--pretty=format:%H%x1f%s%x1f%an%x1f%aI%x1e")
375
407
  end
376
- branch_commits = branch_log_status.success? ? parse_git_log(branch_log_output) : []
408
+ branch_commits = branch_log_status.success? ? GitService.parse_git_log(branch_log_output) : []
377
409
 
378
410
  redmine_ticket_id = nil
379
411
  if Mbeditor.configuration.redmine_enabled
@@ -382,7 +414,7 @@ module Mbeditor
382
414
  redmine_ticket_id = m[1] if m
383
415
  else
384
416
  branch_commits.each do |commit|
385
- m = commit[:title]&.match(/#(\d+)/)
417
+ m = commit["title"]&.match(/#(\d+)/)
386
418
  if m
387
419
  redmine_ticket_id = m[1]
388
420
  break
@@ -545,9 +577,10 @@ module Mbeditor
545
577
 
546
578
  # Use a workspace-local tempfile so RuboCop's config discovery walks up
547
579
  # 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)
580
+ Tempfile.create([".mbeditor_fix_", ext], File.dirname(path)) do |f|
581
+ f.write(code)
582
+ f.flush
583
+ tmpfile = f.path
551
584
 
552
585
  cmd = rubocop_command + ["--no-server", "--cache", "false", "-A", "--no-color", tmpfile]
553
586
  env = { 'RUBOCOP_CACHE_ROOT' => File.join(Dir.tmpdir, 'rubocop') }
@@ -561,8 +594,6 @@ module Mbeditor
561
594
  corrected = File.read(tmpfile, encoding: "UTF-8", invalid: :replace, undef: :replace)
562
595
  fix = compute_text_edit(code, corrected)
563
596
  render json: { fix: fix }
564
- ensure
565
- File.delete(tmpfile) if tmpfile && File.exist?(tmpfile)
566
597
  end
567
598
  rescue StandardError => e
568
599
  render json: { error: e.message }, status: :unprocessable_content
@@ -608,10 +639,12 @@ module Mbeditor
608
639
  code = params[:code].to_s
609
640
  return render json: { error: "code required" }, status: :unprocessable_content if code.empty?
610
641
 
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)
642
+ ext = File.extname(File.basename(path))
643
+ Tempfile.create([".mbeditor_fmt_", ext], File.dirname(path)) do |f|
644
+ f.write(code)
645
+ f.flush
646
+ tmpfile = f.path
647
+
615
648
  cmd = rubocop_command + ["--no-server", "--cache", "false", "-A", "--no-color", tmpfile]
616
649
  env = { 'RUBOCOP_CACHE_ROOT' => File.join(Dir.tmpdir, 'rubocop') }
617
650
  _out, _err, status = Open3.capture3(env, *cmd)
@@ -621,8 +654,6 @@ module Mbeditor
621
654
 
622
655
  corrected = File.read(tmpfile, encoding: "UTF-8", invalid: :replace, undef: :replace)
623
656
  render json: { ok: true, content: corrected }
624
- ensure
625
- File.delete(tmpfile) if tmpfile && File.exist?(tmpfile)
626
657
  end
627
658
  rescue StandardError => e
628
659
  render json: { error: e.message }, status: :unprocessable_content
@@ -679,7 +710,8 @@ module Mbeditor
679
710
 
680
711
  begin
681
712
  data = JSON.parse(raw)
682
- rescue JSON::ParserError
713
+ rescue JSON::ParserError => e
714
+ Rails.logger.warn("[mbeditor] search: malformed rg JSON line: #{e.message}")
683
715
  next
684
716
  end
685
717
  next unless data["type"] == "match"
@@ -694,7 +726,7 @@ module Mbeditor
694
726
  end
695
727
  else
696
728
  args = ["grep", "-rn", "-F"]
697
- excluded_dirnames.each { |d| args << "--exclude-dir=#{d}" }
729
+ excluded_dirnames.select { |d| d.match?(/\A[\w.\/-]+\z/) }.each { |d| args << "--exclude-dir=#{d}" }
698
730
  args += [query, workspace_root.to_s]
699
731
 
700
732
  IO.popen(args, err: File::NULL) do |io|
@@ -722,6 +754,30 @@ module Mbeditor
722
754
  results
723
755
  end
724
756
 
757
+ # Count total matching lines across the workspace using rg --count (or grep -c).
758
+ # Fast: rg just counts without extracting context. Runs in a background thread.
759
+ def count_search_results(query)
760
+ total = 0
761
+ if RG_AVAILABLE
762
+ args = ["rg", "--count", "--no-ignore"]
763
+ excluded_paths.each { |p| args << "--glob=!#{p}" }
764
+ args += ["--", query, workspace_root.to_s]
765
+ IO.popen(args, err: File::NULL) do |io|
766
+ io.each_line { |line| total += line.strip.split(":").last.to_i rescue 0 }
767
+ end
768
+ else
769
+ args = ["grep", "-rc", "-F"]
770
+ excluded_dirnames.each { |d| args << "--exclude-dir=#{d}" }
771
+ args += [query, workspace_root.to_s]
772
+ IO.popen(args, err: File::NULL) do |io|
773
+ io.each_line { |line| total += line.strip.split(":").last.to_i rescue 0 }
774
+ end
775
+ end
776
+ total
777
+ rescue StandardError
778
+ 0
779
+ end
780
+
725
781
  def build_tree(dir, max_depth: 10, depth: 0)
726
782
  return [] if depth >= max_depth
727
783
 
@@ -761,27 +817,29 @@ module Mbeditor
761
817
  end
762
818
 
763
819
  def run_with_timeout(env, cmd, stdin_data:)
764
- output = +""
765
- timed_out = false
820
+ timeout_seconds = Mbeditor.configuration.lint_timeout&.to_i
821
+ output = +""; timed_out = false
766
822
 
767
823
  Open3.popen3(env, *cmd, pgroup: true) do |stdin, stdout, _stderr, wait_thr|
768
824
  stdin.write(stdin_data)
769
825
  stdin.close
770
826
 
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
827
+ timer = if timeout_seconds && timeout_seconds > 0
828
+ Thread.new do
829
+ sleep timeout_seconds
830
+ timed_out = true
831
+ Process.kill('-KILL', wait_thr.pid)
832
+ rescue Errno::ESRCH
833
+ nil
834
+ end
777
835
  end
778
836
 
779
837
  output = stdout.read
780
838
  wait_thr.value
781
- timer.kill
839
+ timer&.kill
782
840
  end
783
841
 
784
- raise "RuboCop timed out after #{RUBOCOP_TIMEOUT_SECONDS} seconds" if timed_out
842
+ raise "RuboCop timed out after #{timeout_seconds} seconds" if timed_out
785
843
 
786
844
  output
787
845
  end
@@ -856,48 +914,46 @@ module Mbeditor
856
914
  candidate.exist? ? ".rubocop.yml" : nil
857
915
  end
858
916
 
917
+ PROBE_MUTEX = Mutex.new
918
+ private_constant :PROBE_MUTEX
919
+
859
920
  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
921
+ key = Mbeditor.configuration.rubocop_command.to_s
922
+ probe_cached(:@rubocop_available_cache, key) do
867
923
  _out, _err, status = Open3.capture3(*rubocop_command, "--version")
868
924
  status.success?
869
- rescue StandardError
870
- false
871
925
  end
872
926
  end
873
927
 
874
928
  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
929
+ cmd = haml_lint_command
930
+ key = cmd.join(" ")
931
+ probe_cached(:@haml_lint_available_cache, key) do
883
932
  _out, _err, status = Open3.capture3(*cmd, "--version")
884
933
  status.success?
885
- rescue StandardError
886
- false
887
934
  end
888
935
  end
889
936
 
890
937
  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
938
+ key = workspace_root.to_s
939
+ probe_cached(:@git_available_cache, key) do
897
940
  _out, _err, status = Open3.capture3("git", "-C", key, "rev-parse", "--is-inside-work-tree")
898
941
  status.success?
899
- rescue StandardError
900
- false
942
+ end
943
+ end
944
+
945
+ def probe_cached(ivar, key, &block)
946
+ PROBE_MUTEX.synchronize do
947
+ cache = self.class.instance_variable_get(ivar) ||
948
+ self.class.instance_variable_set(ivar, {})
949
+ unless cache.key?(key)
950
+ cache[key] = begin
951
+ block.call
952
+ rescue StandardError
953
+ false
954
+ end
955
+ end
956
+ cache[key]
901
957
  end
902
958
  end
903
959
 
@@ -952,8 +1008,13 @@ module Mbeditor
952
1008
  end
953
1009
 
954
1010
  def parse_porcelain_status(output)
955
- output.lines.map do |line|
956
- { status: line[0..1].strip, path: line[3..].to_s.strip }
1011
+ output.lines.filter_map do |line|
1012
+ next if line.length < 4
1013
+
1014
+ path = line[3..].to_s.strip
1015
+ next if path.blank?
1016
+
1017
+ { status: line[0..1].strip, path: path }
957
1018
  end
958
1019
  end
959
1020
 
@@ -970,29 +1031,6 @@ module Mbeditor
970
1031
  end
971
1032
  end
972
1033
 
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
1034
  def monaco_worker_file
997
1035
  engine_path = Mbeditor::Engine.root.join("public", "monaco_worker.js")
998
1036
  return engine_path if engine_path.file?
@@ -1000,38 +1038,6 @@ module Mbeditor
1000
1038
  Rails.root.join("public", "monaco_worker.js")
1001
1039
  end
1002
1040
 
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
1041
  def resolve_monaco_asset_path(asset_path)
1036
1042
  return nil if asset_path.blank?
1037
1043
 
@@ -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
  #