mbeditor 0.5.3 → 0.7.1

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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +77 -0
  3. data/README.md +7 -0
  4. data/app/assets/javascripts/mbeditor/application.js +3 -0
  5. data/app/assets/javascripts/mbeditor/components/ChangelogView.js +145 -0
  6. data/app/assets/javascripts/mbeditor/components/DiffViewer.js +1 -1
  7. data/app/assets/javascripts/mbeditor/components/EditorPanel.js +359 -31
  8. data/app/assets/javascripts/mbeditor/components/FileTree.js +177 -116
  9. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +952 -143
  10. data/app/assets/javascripts/mbeditor/components/TabBar.js +9 -0
  11. data/app/assets/javascripts/mbeditor/conflict_parser.js +48 -0
  12. data/app/assets/javascripts/mbeditor/editor_plugins.js +420 -67
  13. data/app/assets/javascripts/mbeditor/editor_store.js +1 -0
  14. data/app/assets/javascripts/mbeditor/file_service.js +34 -6
  15. data/app/assets/javascripts/mbeditor/git_service.js +2 -1
  16. data/app/assets/javascripts/mbeditor/history_service.js +177 -0
  17. data/app/assets/javascripts/mbeditor/search_service.js +1 -0
  18. data/app/assets/javascripts/mbeditor/tab_manager.js +8 -5
  19. data/app/assets/stylesheets/mbeditor/application.css +112 -0
  20. data/app/assets/stylesheets/mbeditor/editor.css +443 -78
  21. data/app/channels/mbeditor/editor_channel.rb +5 -41
  22. data/app/controllers/mbeditor/application_controller.rb +8 -1
  23. data/app/controllers/mbeditor/editors_controller.rb +276 -654
  24. data/app/controllers/mbeditor/git_controller.rb +2 -61
  25. data/app/services/mbeditor/availability_probe.rb +83 -0
  26. data/app/services/mbeditor/code_search_service.rb +42 -0
  27. data/app/services/mbeditor/editor_state_service.rb +91 -0
  28. data/app/services/mbeditor/exclusion_matcher.rb +23 -0
  29. data/app/services/mbeditor/file_operation_service.rb +68 -0
  30. data/app/services/mbeditor/file_tree_service.rb +69 -0
  31. data/app/services/mbeditor/git_combined_diff_service.rb +43 -0
  32. data/app/services/mbeditor/git_commit_detail_service.rb +46 -0
  33. data/app/services/mbeditor/git_info_service.rb +151 -0
  34. data/app/services/mbeditor/git_service.rb +36 -26
  35. data/app/services/mbeditor/js_definition_service.rb +59 -0
  36. data/app/services/mbeditor/js_members_service.rb +62 -0
  37. data/app/services/mbeditor/process_runner.rb +48 -0
  38. data/app/services/mbeditor/rails_related_files_service.rb +282 -0
  39. data/app/services/mbeditor/ruby_definition_service.rb +77 -101
  40. data/app/services/mbeditor/schema_service.rb +270 -0
  41. data/app/services/mbeditor/search_replace_service.rb +184 -0
  42. data/app/services/mbeditor/test_runner_service.rb +5 -27
  43. data/app/views/layouts/mbeditor/application.html.erb +2 -2
  44. data/config/routes.rb +8 -1
  45. data/lib/mbeditor/configuration.rb +4 -2
  46. data/lib/mbeditor/version.rb +1 -1
  47. data/public/monaco-editor/vs/language/css/cssMode.js +13 -0
  48. data/public/monaco-editor/vs/language/css/cssWorker.js +77 -0
  49. data/public/monaco-editor/vs/language/html/htmlMode.js +13 -0
  50. data/public/monaco-editor/vs/language/html/htmlWorker.js +454 -0
  51. data/public/monaco-editor/vs/language/json/jsonMode.js +19 -0
  52. data/public/monaco-editor/vs/language/json/jsonWorker.js +42 -0
  53. metadata +26 -3
  54. data/app/services/mbeditor/unused_methods_service.rb +0 -139
@@ -80,38 +80,7 @@ module Mbeditor
80
80
  return render json: { error: "sha required" }, status: :bad_request if sha.blank?
81
81
  return render json: { error: "Invalid sha" }, status: :bad_request unless sha.match?(/\A[0-9a-fA-F]{1,40}\z/)
82
82
 
83
- files_output, _err, files_status = Open3.capture3(
84
- "git", "-C", workspace_root.to_s,
85
- "diff-tree", "--no-commit-id", "-r", "--name-status", sha
86
- )
87
- numstat_output, _err, numstat_status = Open3.capture3(
88
- "git", "-C", workspace_root.to_s,
89
- "diff-tree", "--no-commit-id", "-r", "--numstat", sha
90
- )
91
-
92
- numstat_map = numstat_status.success? ? GitService.parse_numstat(numstat_output) : {}
93
-
94
- files = []
95
- if files_status.success?
96
- files = files_output.lines.map do |line|
97
- parts = line.strip.split("\t", 2)
98
- next if parts.length < 2
99
- file = { "status" => parts[0].strip, "path" => parts[1].strip }
100
- file.merge(numstat_map.fetch(file["path"], {}))
101
- end.compact
102
- end
103
-
104
- log_output, _err, log_status = Open3.capture3(
105
- "git", "-C", workspace_root.to_s,
106
- "log", "-1", "--pretty=format:%s%x1f%an%x1f%aI", sha
107
- )
108
- meta = {}
109
- if log_status.success?
110
- fields = log_output.strip.split("\x1f", 3)
111
- meta = { "title" => fields[0].to_s, "author" => fields[1].to_s, "date" => fields[2].to_s }
112
- end
113
-
114
- render json: { sha: sha, title: meta["title"] || "", author: meta["author"] || "", date: meta["date"] || "", files: files }
83
+ render json: GitCommitDetailService.new(repo_path: workspace_root, sha: sha).call
115
84
  rescue StandardError => e
116
85
  render json: { error: e.message }, status: :unprocessable_content
117
86
  end
@@ -122,35 +91,7 @@ module Mbeditor
122
91
  # scope=branch → git diff <branch-base>..HEAD (same baseline as git_info)
123
92
  def combined_diff
124
93
  scope = params[:scope] == 'branch' ? :branch : :local
125
-
126
- if scope == :local
127
- out, _err, status = Open3.capture3("git", "-C", workspace_root.to_s, "diff", "HEAD")
128
- out = status.success? ? out : ""
129
- else
130
- repo = workspace_root.to_s
131
- branch = GitService.current_branch(repo)
132
- base_sha, = GitService.find_branch_base(repo, branch)
133
-
134
- if base_sha.present?
135
- out, _err, status = Open3.capture3("git", "-C", repo, "diff", "#{base_sha}..HEAD")
136
- out = status.success? ? out : ""
137
- else
138
- upstream_out, _err, upstream_status = Open3.capture3(
139
- "git", "-C", repo,
140
- "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"
141
- )
142
- upstream = upstream_status.success? ? upstream_out.strip : nil
143
- upstream = nil unless upstream&.match?(%r{\A[\w./-]+\z})
144
-
145
- if upstream.present?
146
- out, _err, status = Open3.capture3("git", "-C", repo, "diff", "#{upstream}..HEAD")
147
- out = status.success? ? out : ""
148
- else
149
- return render plain: "", content_type: "text/plain"
150
- end
151
- end
152
- end
153
-
94
+ out = GitCombinedDiffService.new(repo_path: workspace_root, scope: scope).call
154
95
  render plain: out, content_type: "text/plain"
155
96
  rescue StandardError
156
97
  render plain: "", content_type: "text/plain"
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "shellwords"
5
+
6
+ module Mbeditor
7
+ class AvailabilityProbe
8
+ MUTEX = Mutex.new
9
+ private_constant :MUTEX
10
+
11
+ def self.rubocop_command(workspace_root)
12
+ root = Pathname.new(workspace_root)
13
+ command = Mbeditor.configuration.rubocop_command.to_s.strip
14
+ command = "rubocop" if command.empty?
15
+ tokens = Shellwords.split(command)
16
+
17
+ local_bin = root.join("bin", "rubocop")
18
+ return [local_bin.to_s] if tokens == ["rubocop"] && local_bin.exist?
19
+
20
+ tokens
21
+ rescue ArgumentError
22
+ ["rubocop"]
23
+ end
24
+
25
+ def self.haml_lint_command(workspace_root)
26
+ root = Pathname.new(workspace_root)
27
+ workspace_bin = root.join("bin", "haml-lint")
28
+ return [workspace_bin.to_s] if workspace_bin.exist?
29
+
30
+ begin
31
+ [Gem.bin_path("haml_lint", "haml-lint")]
32
+ rescue Gem::Exception, Gem::GemNotFoundException
33
+ ["haml-lint"]
34
+ end
35
+ end
36
+
37
+ def self.rubocop(workspace_root)
38
+ key = "rubocop:#{Mbeditor.configuration.rubocop_command}"
39
+ probe_cached(key) do
40
+ cmd = rubocop_command(workspace_root)
41
+ _out, _err, status = Open3.capture3(*cmd, "--version")
42
+ status.success?
43
+ end
44
+ end
45
+
46
+ def self.haml_lint(workspace_root)
47
+ cmd = haml_lint_command(workspace_root)
48
+ key = "haml_lint:#{cmd.join(' ')}"
49
+ probe_cached(key) do
50
+ _out, _err, status = Open3.capture3(*cmd, "--version")
51
+ status.success?
52
+ end
53
+ end
54
+
55
+ def self.git(workspace_root)
56
+ key = "git:#{workspace_root}"
57
+ probe_cached(key) do
58
+ _out, _err, status = Open3.capture3("git", "-C", workspace_root.to_s, "rev-parse", "--is-inside-work-tree")
59
+ status.success?
60
+ end
61
+ end
62
+
63
+ def self.reset!
64
+ MUTEX.synchronize { @cache = {} }
65
+ nil
66
+ end
67
+
68
+ def self.probe_cached(key)
69
+ MUTEX.synchronize do
70
+ @cache ||= {}
71
+ unless @cache.key?(key)
72
+ @cache[key] = begin
73
+ yield
74
+ rescue StandardError
75
+ false
76
+ end
77
+ end
78
+ @cache[key]
79
+ end
80
+ end
81
+ private_class_method :probe_cached
82
+ end
83
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Mbeditor
6
+ class CodeSearchService
7
+ JS_GLOBS = %w[*.js *.jsx *.ts *.tsx *.js.jsx *.js.erb *.jsx.erb].freeze
8
+
9
+ class << self
10
+ def call(pattern, workspace_root, globs: JS_GLOBS)
11
+ if SearchReplaceService::RG_AVAILABLE
12
+ run_rg(pattern, workspace_root, globs)
13
+ else
14
+ run_grep(pattern, workspace_root, globs)
15
+ end
16
+ rescue StandardError
17
+ []
18
+ end
19
+
20
+ private
21
+
22
+ def run_rg(pattern, workspace_root, globs)
23
+ args = ["rg", "--no-heading", "-n", "--color=never", "-e", pattern]
24
+ args += globs.flat_map { |g| ["-g", g] }
25
+ args << workspace_root
26
+ out, = Open3.capture2(*args)
27
+ out.lines
28
+ rescue StandardError
29
+ []
30
+ end
31
+
32
+ def run_grep(pattern, workspace_root, globs)
33
+ includes = globs.map { |g| "--include=#{g}" }
34
+ args = ["grep", "-rn", "--color=never", "-E", pattern] + includes + [workspace_root]
35
+ out, = Open3.capture2(*args)
36
+ out.lines
37
+ rescue StandardError
38
+ []
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mbeditor
4
+ class EditorStateService
5
+ PayloadTooLargeError = Class.new(StandardError)
6
+ InvalidBranchError = Class.new(StandardError)
7
+
8
+ STATE_MAX_BYTES = 1 * 1024 * 1024
9
+ SAFE_BRANCH_NAME = /\A[a-zA-Z0-9._\-\/]+\z/
10
+
11
+ def initialize(workspace_root)
12
+ @root = workspace_root
13
+ end
14
+
15
+ def read_state
16
+ path = workspace_path
17
+ return {} unless File.exist?(path)
18
+ JSON.parse(File.read(path))
19
+ rescue JSON::ParserError, Errno::ENOENT
20
+ {}
21
+ end
22
+
23
+ def read_branch_state(branch)
24
+ path = branch_states_path
25
+ return {} unless File.exist?(path)
26
+ all = JSON.parse(File.read(path))
27
+ all[branch] || {}
28
+ rescue JSON::ParserError, Errno::ENOENT
29
+ {}
30
+ end
31
+
32
+ def write_branch_state(branch, state)
33
+ raise InvalidBranchError, "Invalid branch name" unless branch.match?(SAFE_BRANCH_NAME)
34
+ payload_json = state.to_json
35
+ raise PayloadTooLargeError, "State payload too large" if payload_json.bytesize > STATE_MAX_BYTES
36
+ path = branch_states_path
37
+ FileUtils.mkdir_p(path.dirname)
38
+ File.open(path, File::RDWR | File::CREAT) do |f|
39
+ f.flock(File::LOCK_EX)
40
+ existing = f.size > 0 ? JSON.parse(f.read) : {}
41
+ existing[branch] = state
42
+ f.truncate(0)
43
+ f.rewind
44
+ f.write(existing.to_json)
45
+ end
46
+ nil
47
+ end
48
+
49
+ def prune_branch_states(active_branches:)
50
+ path = branch_states_path
51
+ return [] unless File.exist?(path)
52
+ pruned = []
53
+ File.open(path, File::RDWR) do |f|
54
+ f.flock(File::LOCK_EX)
55
+ all = JSON.parse(f.read) rescue {}
56
+ pruned = all.keys - active_branches
57
+ if pruned.any?
58
+ pruned.each { |b| all.delete(b) }
59
+ f.truncate(0)
60
+ f.rewind
61
+ f.write(all.to_json)
62
+ end
63
+ end
64
+ pruned
65
+ end
66
+
67
+ def write_state(state)
68
+ payload = state.to_json
69
+ raise PayloadTooLargeError, "State payload too large" if payload.bytesize > STATE_MAX_BYTES
70
+ path = workspace_path
71
+ FileUtils.mkdir_p(path.dirname)
72
+ File.open(path, File::RDWR | File::CREAT) do |f|
73
+ f.flock(File::LOCK_EX)
74
+ f.truncate(0)
75
+ f.rewind
76
+ f.write(payload)
77
+ end
78
+ nil
79
+ end
80
+
81
+ private
82
+
83
+ def workspace_path
84
+ @root.join("tmp", "mbeditor_workspace.json")
85
+ end
86
+
87
+ def branch_states_path
88
+ @root.join("tmp", "mbeditor_branch_states.json")
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mbeditor
4
+ class ExclusionMatcher
5
+ def initialize(patterns)
6
+ @patterns = patterns.map(&:to_s).reject(&:empty?)
7
+ end
8
+
9
+ def excluded?(relative_path)
10
+ @patterns.any? { |pattern| matches?(pattern, relative_path) }
11
+ end
12
+
13
+ private
14
+
15
+ def matches?(pattern, rel)
16
+ if pattern.include?("/")
17
+ rel == pattern || rel.start_with?("#{pattern}/")
18
+ else
19
+ File.basename(rel) == pattern || rel.split("/").include?(pattern)
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module Mbeditor
6
+ class FileOperationService
7
+ MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024
8
+
9
+ class FileTooLargeError < StandardError; end
10
+ class FileExistsError < StandardError; end
11
+ class PathNotFoundError < StandardError; end
12
+ class TargetExistsError < StandardError; end
13
+
14
+ def initialize(workspace_root)
15
+ @workspace_root = Pathname(workspace_root)
16
+ end
17
+
18
+ def save(path, content)
19
+ raise FileTooLargeError if content.bytesize > MAX_FILE_SIZE_BYTES
20
+
21
+ File.write(path, content)
22
+ { ok: true, path: relative_path(path) }
23
+ end
24
+
25
+ def destroy_path(path)
26
+ return { ok: true } unless File.exist?(path)
27
+
28
+ if File.directory?(path)
29
+ FileUtils.rm_rf(path)
30
+ { ok: true, type: "folder", path: relative_path(path) }
31
+ else
32
+ File.delete(path)
33
+ { ok: true, type: "file", path: relative_path(path) }
34
+ end
35
+ end
36
+
37
+ def rename(old_path, new_path)
38
+ raise PathNotFoundError unless File.exist?(old_path)
39
+ raise TargetExistsError if File.exist?(new_path)
40
+
41
+ FileUtils.mkdir_p(File.dirname(new_path))
42
+ FileUtils.mv(old_path, new_path)
43
+ { ok: true, oldPath: relative_path(old_path), path: relative_path(new_path), name: File.basename(new_path) }
44
+ end
45
+
46
+ def create_dir(path)
47
+ raise FileExistsError if File.exist?(path)
48
+
49
+ FileUtils.mkdir_p(path)
50
+ { ok: true, type: "folder", path: relative_path(path), name: File.basename(path) }
51
+ end
52
+
53
+ def create_file(path, content)
54
+ raise FileTooLargeError if content.bytesize > MAX_FILE_SIZE_BYTES
55
+ raise FileExistsError if File.exist?(path)
56
+
57
+ FileUtils.mkdir_p(File.dirname(path))
58
+ File.write(path, content)
59
+ { ok: true, type: "file", path: relative_path(path), name: File.basename(path) }
60
+ end
61
+
62
+ private
63
+
64
+ def relative_path(path)
65
+ Pathname(path).relative_path_from(@workspace_root).to_s
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mbeditor
4
+ class FileTreeService
5
+ MUTEX = Mutex.new
6
+ private_constant :MUTEX
7
+
8
+ def self.build(workspace_root)
9
+ root = workspace_root.to_s
10
+ MUTEX.synchronize do
11
+ @cache ||= {}
12
+ entry = @cache[root]
13
+ if entry && (Process.clock_gettime(Process::CLOCK_MONOTONIC) - entry[:ts]) < 15
14
+ return entry[:data]
15
+ end
16
+ end
17
+
18
+ matcher = ExclusionMatcher.new(Mbeditor.configuration.excluded_paths)
19
+ data = traverse(root, root, matcher)
20
+
21
+ MUTEX.synchronize do
22
+ @cache ||= {}
23
+ @cache[root] = { ts: Process.clock_gettime(Process::CLOCK_MONOTONIC), data: data }
24
+ end
25
+
26
+ data
27
+ end
28
+
29
+ def self.invalidate(workspace_root)
30
+ MUTEX.synchronize do
31
+ @cache ||= {}
32
+ @cache.delete(workspace_root.to_s)
33
+ end
34
+ nil
35
+ end
36
+
37
+ def self.reset!
38
+ MUTEX.synchronize { @cache = {} }
39
+ nil
40
+ end
41
+
42
+ def self.traverse(dir, workspace_root, matcher, max_depth: 10, depth: 0)
43
+ return [] if depth >= max_depth
44
+
45
+ entries = Dir.entries(dir).sort.reject { |e| e == "." || e == ".." }
46
+ entries.filter_map do |name|
47
+ full = File.join(dir, name)
48
+ rel = full.delete_prefix(workspace_root + "/")
49
+ rel = "" if rel == workspace_root
50
+ is_excl = matcher.excluded?(rel)
51
+
52
+ if File.directory?(full)
53
+ children = is_excl ? [] : traverse(full, workspace_root, matcher, max_depth: max_depth, depth: depth + 1)
54
+ node = { name: name, type: "folder", path: rel, children: children }
55
+ node[:excluded] = true if is_excl
56
+ node
57
+ else
58
+ node = { name: name, type: "file", path: rel }
59
+ node[:size] = (File.size(full) rescue nil) unless is_excl
60
+ node[:excluded] = true if is_excl
61
+ node
62
+ end
63
+ end
64
+ rescue Errno::EACCES
65
+ []
66
+ end
67
+ private_class_method :traverse
68
+ end
69
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mbeditor
4
+ class GitCombinedDiffService
5
+ include GitService
6
+
7
+ attr_reader :repo_path, :scope
8
+
9
+ def initialize(repo_path:, scope:)
10
+ @repo_path = repo_path.to_s
11
+ @scope = scope
12
+ end
13
+
14
+ def call
15
+ return local_diff if scope == :local
16
+
17
+ branch_diff
18
+ end
19
+
20
+ private
21
+
22
+ def local_diff
23
+ out, status = GitService.run_git(repo_path, "diff", "HEAD")
24
+ status.success? ? out : ""
25
+ end
26
+
27
+ def branch_diff
28
+ branch = GitService.current_branch(repo_path)
29
+ base_sha, = GitService.find_branch_base(repo_path, branch)
30
+
31
+ if base_sha.present?
32
+ out, status = GitService.run_git(repo_path, "diff", "#{base_sha}..HEAD")
33
+ return status.success? ? out : ""
34
+ end
35
+
36
+ upstream = GitService.upstream_branch(repo_path)
37
+ return "" unless upstream.present?
38
+
39
+ out, status = GitService.run_git(repo_path, "diff", "#{upstream}..HEAD")
40
+ status.success? ? out : ""
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mbeditor
4
+ class GitCommitDetailService
5
+ include GitService
6
+
7
+ attr_reader :repo_path, :sha
8
+
9
+ def initialize(repo_path:, sha:)
10
+ @repo_path = repo_path.to_s
11
+ @sha = sha.to_s
12
+ end
13
+
14
+ def call
15
+ meta = fetch_meta
16
+ { "sha" => sha, "title" => meta["title"], "author" => meta["author"], "date" => meta["date"], "files" => fetch_files }
17
+ end
18
+
19
+ private
20
+
21
+ def fetch_files
22
+ name_out, name_st = GitService.run_git(repo_path, "diff-tree", "--no-commit-id", "-r", "--name-status", sha)
23
+ return [] unless name_st.success?
24
+
25
+ num_out, num_st = GitService.run_git(repo_path, "diff-tree", "--no-commit-id", "-r", "--numstat", sha)
26
+ numstat = num_st.success? ? GitService.parse_numstat(num_out) : {}
27
+
28
+ name_out.lines.filter_map do |line|
29
+ parts = line.strip.split("\t", 2)
30
+ next if parts.length < 2
31
+
32
+ path = parts[1].strip
33
+ stats = numstat.fetch(path, { added: 0, removed: 0 })
34
+ { "status" => parts[0].strip, "path" => path, "added" => stats[:added], "removed" => stats[:removed] }
35
+ end
36
+ end
37
+
38
+ def fetch_meta
39
+ out, status = GitService.run_git(repo_path, "log", "-1", "--pretty=format:%s%x1f%an%x1f%aI", sha)
40
+ return { "title" => "", "author" => "", "date" => "" } unless status.success?
41
+
42
+ fields = out.strip.split("\x1f", 3)
43
+ { "title" => fields[0].to_s, "author" => fields[1].to_s, "date" => fields[2].to_s }
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Mbeditor
6
+ module GitInfoService
7
+ module_function
8
+
9
+ GIT_INFO_MUTEX = Mutex.new
10
+ private_constant :GIT_INFO_MUTEX
11
+
12
+ def call(repo_path)
13
+ cached = cached_git_info(repo_path)
14
+ return cached if cached
15
+
16
+ branch = GitService.current_branch(repo_path)
17
+ unless branch
18
+ return { ok: false, error: "Unable to determine current branch" }
19
+ end
20
+
21
+ # Wave 1: all independent git reads run in parallel
22
+ status_t = Thread.new { Open3.capture3("git", "-C", repo_path, "status", "--porcelain") }
23
+ numstat_t = Thread.new { Open3.capture3("git", "-C", repo_path, "diff", "--numstat", "HEAD") }
24
+ upstream_t = Thread.new { Open3.capture3("git", "-C", repo_path, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}") }
25
+ base_t = Thread.new { GitService.find_branch_base(repo_path, branch) }
26
+
27
+ working_output, _err, working_status = status_t.value
28
+ working_tree = working_status.success? ? GitService.parse_porcelain_status(working_output) : []
29
+
30
+ numstat_out = numstat_t.value.first
31
+ numstat_map = GitService.parse_numstat(numstat_out)
32
+ working_tree = working_tree.map { |f| f.merge(numstat_map.fetch(f[:path], {})) }
33
+
34
+ upstream_output, _err, upstream_status = upstream_t.value
35
+ upstream_branch = upstream_status.success? ? upstream_output.strip : nil
36
+ upstream_branch = nil unless upstream_branch&.match?(%r{\A[\w./-]+\z})
37
+
38
+ base_sha, base_ref = base_t.value
39
+
40
+ ahead_count = 0
41
+ behind_count = 0
42
+ unpushed_files = []
43
+ unpushed_commits = []
44
+ diff_base = base_sha || upstream_branch
45
+
46
+ # Wave 2: conditional parallel reads that depend on Wave 1 results
47
+ wave2 = {}
48
+ wave2[:counts] = Thread.new { Open3.capture3("git", "-C", repo_path, "rev-list", "--left-right", "--count", "HEAD...#{upstream_branch}") } if upstream_branch.to_s != ""
49
+ wave2[:unp_log] = Thread.new { Open3.capture3("git", "-C", repo_path, "log", "#{upstream_branch}..HEAD", "--pretty=format:%H%x1f%s%x1f%an%x1f%aI%x1e") } if upstream_branch.to_s != ""
50
+ wave2[:diff_name] = Thread.new { Open3.capture3("git", "-C", repo_path, "diff", "--name-status", "#{diff_base}..HEAD") } if diff_base.to_s != ""
51
+ wave2[:diff_num] = Thread.new { Open3.capture3("git", "-C", repo_path, "diff", "--numstat", "#{diff_base}..HEAD") } if diff_base.to_s != ""
52
+ wave2[:branch_log] = Thread.new do
53
+ if base_sha
54
+ Open3.capture3("git", "-C", repo_path, "log", "--first-parent", "#{base_sha}..HEAD",
55
+ "--pretty=format:%H%x1f%s%x1f%an%x1f%aI%x1e")
56
+ else
57
+ Open3.capture3("git", "-C", repo_path, "log", "--first-parent", branch, "-n", "100",
58
+ "--pretty=format:%H%x1f%s%x1f%an%x1f%aI%x1e")
59
+ end
60
+ end
61
+
62
+ wave2.each_value(&:join)
63
+
64
+ if (ct = wave2[:counts])
65
+ counts_output, _err, counts_status = ct.value
66
+ if counts_status.success?
67
+ ahead_str, behind_str = counts_output.strip.split("\t", 2)
68
+ ahead_count = ahead_str.to_i
69
+ behind_count = behind_str.to_i
70
+ end
71
+ end
72
+
73
+ if (ul = wave2[:unp_log])
74
+ unpushed_log_output, _err, unpushed_log_status = ul.value
75
+ unpushed_commits = GitService.parse_git_log(unpushed_log_output) if unpushed_log_status.success?
76
+ end
77
+
78
+ if (dn = wave2[:diff_name]) && (dnum = wave2[:diff_num])
79
+ diff_name_out, _err, diff_name_status = dn.value
80
+ if diff_name_status.success?
81
+ unpushed_files = GitService.parse_name_status(diff_name_out)
82
+ unp_numstat_out = dnum.value.first
83
+ unp_numstat_map = GitService.parse_numstat(unp_numstat_out)
84
+ unpushed_files = unpushed_files.map { |f| f.merge(unp_numstat_map.fetch(f[:path], {})) }
85
+ end
86
+ end
87
+
88
+ branch_log_output, _err, branch_log_status = wave2[:branch_log].value
89
+ branch_commits = branch_log_status.success? ? GitService.parse_git_log(branch_log_output) : []
90
+
91
+ redmine_ticket_id = nil
92
+ if Mbeditor.configuration.redmine_enabled
93
+ if Mbeditor.configuration.redmine_ticket_source == :branch
94
+ m = branch.match(/\A(\d+)/)
95
+ redmine_ticket_id = m[1] if m
96
+ else
97
+ branch_commits.each do |commit|
98
+ m = commit["title"]&.match(/#(\d+)/)
99
+ if m
100
+ redmine_ticket_id = m[1]
101
+ break
102
+ end
103
+ end
104
+ end
105
+ end
106
+
107
+ payload = {
108
+ ok: true,
109
+ branch: branch,
110
+ upstreamBranch: upstream_branch,
111
+ ahead: ahead_count,
112
+ behind: behind_count,
113
+ workingTree: working_tree,
114
+ unpushedFiles: unpushed_files,
115
+ unpushedCommits: unpushed_commits,
116
+ branchCommits: branch_commits,
117
+ branchBaseRef: base_ref,
118
+ redmineTicketId: redmine_ticket_id
119
+ }
120
+ store_git_info(repo_path, payload)
121
+ payload
122
+ rescue StandardError => e
123
+ { ok: false, error: e.message }
124
+ end
125
+
126
+ def invalidate(repo_path)
127
+ GIT_INFO_MUTEX.synchronize do
128
+ cache = instance_variable_get(:@git_info_cache) || {}
129
+ cache.delete(repo_path)
130
+ instance_variable_set(:@git_info_cache, cache)
131
+ end
132
+ end
133
+
134
+ def cached_git_info(repo_path, ttl: 5)
135
+ GIT_INFO_MUTEX.synchronize do
136
+ cache = instance_variable_get(:@git_info_cache) || {}
137
+ entry = cache[repo_path]
138
+ return entry[:data] if entry && (Process.clock_gettime(Process::CLOCK_MONOTONIC) - entry[:ts]) < ttl
139
+ end
140
+ nil
141
+ end
142
+
143
+ def store_git_info(repo_path, data)
144
+ GIT_INFO_MUTEX.synchronize do
145
+ cache = instance_variable_get(:@git_info_cache) || {}
146
+ cache[repo_path] = { ts: Process.clock_gettime(Process::CLOCK_MONOTONIC), data: data }
147
+ instance_variable_set(:@git_info_cache, cache)
148
+ end
149
+ end
150
+ end
151
+ end