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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +77 -0
- data/README.md +7 -0
- data/app/assets/javascripts/mbeditor/application.js +3 -0
- data/app/assets/javascripts/mbeditor/components/ChangelogView.js +145 -0
- data/app/assets/javascripts/mbeditor/components/DiffViewer.js +1 -1
- data/app/assets/javascripts/mbeditor/components/EditorPanel.js +359 -31
- data/app/assets/javascripts/mbeditor/components/FileTree.js +177 -116
- data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +952 -143
- data/app/assets/javascripts/mbeditor/components/TabBar.js +9 -0
- data/app/assets/javascripts/mbeditor/conflict_parser.js +48 -0
- data/app/assets/javascripts/mbeditor/editor_plugins.js +420 -67
- data/app/assets/javascripts/mbeditor/editor_store.js +1 -0
- data/app/assets/javascripts/mbeditor/file_service.js +34 -6
- data/app/assets/javascripts/mbeditor/git_service.js +2 -1
- data/app/assets/javascripts/mbeditor/history_service.js +177 -0
- data/app/assets/javascripts/mbeditor/search_service.js +1 -0
- data/app/assets/javascripts/mbeditor/tab_manager.js +8 -5
- data/app/assets/stylesheets/mbeditor/application.css +112 -0
- data/app/assets/stylesheets/mbeditor/editor.css +443 -78
- data/app/channels/mbeditor/editor_channel.rb +5 -41
- data/app/controllers/mbeditor/application_controller.rb +8 -1
- data/app/controllers/mbeditor/editors_controller.rb +276 -654
- data/app/controllers/mbeditor/git_controller.rb +2 -61
- data/app/services/mbeditor/availability_probe.rb +83 -0
- data/app/services/mbeditor/code_search_service.rb +42 -0
- data/app/services/mbeditor/editor_state_service.rb +91 -0
- data/app/services/mbeditor/exclusion_matcher.rb +23 -0
- data/app/services/mbeditor/file_operation_service.rb +68 -0
- data/app/services/mbeditor/file_tree_service.rb +69 -0
- data/app/services/mbeditor/git_combined_diff_service.rb +43 -0
- data/app/services/mbeditor/git_commit_detail_service.rb +46 -0
- data/app/services/mbeditor/git_info_service.rb +151 -0
- data/app/services/mbeditor/git_service.rb +36 -26
- data/app/services/mbeditor/js_definition_service.rb +59 -0
- data/app/services/mbeditor/js_members_service.rb +62 -0
- data/app/services/mbeditor/process_runner.rb +48 -0
- data/app/services/mbeditor/rails_related_files_service.rb +282 -0
- data/app/services/mbeditor/ruby_definition_service.rb +77 -101
- data/app/services/mbeditor/schema_service.rb +270 -0
- data/app/services/mbeditor/search_replace_service.rb +184 -0
- data/app/services/mbeditor/test_runner_service.rb +5 -27
- data/app/views/layouts/mbeditor/application.html.erb +2 -2
- data/config/routes.rb +8 -1
- data/lib/mbeditor/configuration.rb +4 -2
- data/lib/mbeditor/version.rb +1 -1
- data/public/monaco-editor/vs/language/css/cssMode.js +13 -0
- data/public/monaco-editor/vs/language/css/cssWorker.js +77 -0
- data/public/monaco-editor/vs/language/html/htmlMode.js +13 -0
- data/public/monaco-editor/vs/language/html/htmlWorker.js +454 -0
- data/public/monaco-editor/vs/language/json/jsonMode.js +19 -0
- data/public/monaco-editor/vs/language/json/jsonWorker.js +42 -0
- metadata +26 -3
- 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
|
-
|
|
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
|