chocomint 1.0.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.
Files changed (158) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +224 -0
  4. data/THIRD_PARTY_LICENSES.md +45 -0
  5. data/bin/chocomint +37 -0
  6. data/bin/chocomint-console-worker +35 -0
  7. data/bin/chocomint-server +43 -0
  8. data/config/config.yml +37 -0
  9. data/lib/chocomint/config.rb +74 -0
  10. data/lib/chocomint/console/conpty.rb +296 -0
  11. data/lib/chocomint/console/server.rb +164 -0
  12. data/lib/chocomint/console/worker.rb +88 -0
  13. data/lib/chocomint/console/worker_process.rb +254 -0
  14. data/lib/chocomint/edit_git_handlers.rb +397 -0
  15. data/lib/chocomint/edit_handlers.rb +696 -0
  16. data/lib/chocomint/edit_html.rb +9 -0
  17. data/lib/chocomint/errors.rb +18 -0
  18. data/lib/chocomint/factory.rb +145 -0
  19. data/lib/chocomint/llm/base_client.rb +113 -0
  20. data/lib/chocomint/llm/chat_client.rb +206 -0
  21. data/lib/chocomint/llm/primary_client.rb +320 -0
  22. data/lib/chocomint/llm/verifier_client.rb +127 -0
  23. data/lib/chocomint/logger/sqlite_logger.rb +245 -0
  24. data/lib/chocomint/orchestrator.rb +175 -0
  25. data/lib/chocomint/planner.rb +241 -0
  26. data/lib/chocomint/security/path_guard.rb +84 -0
  27. data/lib/chocomint/server.rb +863 -0
  28. data/lib/chocomint/tools/append_file.rb +50 -0
  29. data/lib/chocomint/tools/base.rb +88 -0
  30. data/lib/chocomint/tools/delete_file.rb +42 -0
  31. data/lib/chocomint/tools/edit.rb +65 -0
  32. data/lib/chocomint/tools/fetch_url.rb +156 -0
  33. data/lib/chocomint/tools/file_info.rb +54 -0
  34. data/lib/chocomint/tools/glob.rb +58 -0
  35. data/lib/chocomint/tools/grep.rb +78 -0
  36. data/lib/chocomint/tools/list_dir.rb +41 -0
  37. data/lib/chocomint/tools/ls.rb +51 -0
  38. data/lib/chocomint/tools/make_dir.rb +44 -0
  39. data/lib/chocomint/tools/read_file.rb +45 -0
  40. data/lib/chocomint/tools/registry.rb +29 -0
  41. data/lib/chocomint/tools/run_command.rb +96 -0
  42. data/lib/chocomint/tools/shell.rb +169 -0
  43. data/lib/chocomint/tools/write_file.rb +54 -0
  44. data/lib/chocomint/validator/machine_validator.rb +65 -0
  45. data/lib/chocomint/validator/semantic_validator.rb +52 -0
  46. data/lib/chocomint.rb +51 -0
  47. data/public/edit/edit.css +414 -0
  48. data/public/edit/edit.js +2137 -0
  49. data/public/edit/index.html +251 -0
  50. data/public/vendor/monaco/LICENSE.txt +23 -0
  51. data/public/vendor/monaco/vs/base/browser/ui/codicons/codicon/codicon.ttf +0 -0
  52. data/public/vendor/monaco/vs/base/worker/workerMain.js +31 -0
  53. data/public/vendor/monaco/vs/basic-languages/abap/abap.js +10 -0
  54. data/public/vendor/monaco/vs/basic-languages/apex/apex.js +10 -0
  55. data/public/vendor/monaco/vs/basic-languages/azcli/azcli.js +10 -0
  56. data/public/vendor/monaco/vs/basic-languages/bat/bat.js +10 -0
  57. data/public/vendor/monaco/vs/basic-languages/bicep/bicep.js +11 -0
  58. data/public/vendor/monaco/vs/basic-languages/cameligo/cameligo.js +10 -0
  59. data/public/vendor/monaco/vs/basic-languages/clojure/clojure.js +10 -0
  60. data/public/vendor/monaco/vs/basic-languages/coffee/coffee.js +10 -0
  61. data/public/vendor/monaco/vs/basic-languages/cpp/cpp.js +10 -0
  62. data/public/vendor/monaco/vs/basic-languages/csharp/csharp.js +10 -0
  63. data/public/vendor/monaco/vs/basic-languages/csp/csp.js +10 -0
  64. data/public/vendor/monaco/vs/basic-languages/css/css.js +12 -0
  65. data/public/vendor/monaco/vs/basic-languages/cypher/cypher.js +10 -0
  66. data/public/vendor/monaco/vs/basic-languages/dart/dart.js +10 -0
  67. data/public/vendor/monaco/vs/basic-languages/dockerfile/dockerfile.js +10 -0
  68. data/public/vendor/monaco/vs/basic-languages/ecl/ecl.js +10 -0
  69. data/public/vendor/monaco/vs/basic-languages/elixir/elixir.js +10 -0
  70. data/public/vendor/monaco/vs/basic-languages/flow9/flow9.js +10 -0
  71. data/public/vendor/monaco/vs/basic-languages/freemarker2/freemarker2.js +12 -0
  72. data/public/vendor/monaco/vs/basic-languages/fsharp/fsharp.js +10 -0
  73. data/public/vendor/monaco/vs/basic-languages/go/go.js +10 -0
  74. data/public/vendor/monaco/vs/basic-languages/graphql/graphql.js +10 -0
  75. data/public/vendor/monaco/vs/basic-languages/handlebars/handlebars.js +10 -0
  76. data/public/vendor/monaco/vs/basic-languages/hcl/hcl.js +10 -0
  77. data/public/vendor/monaco/vs/basic-languages/html/html.js +10 -0
  78. data/public/vendor/monaco/vs/basic-languages/ini/ini.js +10 -0
  79. data/public/vendor/monaco/vs/basic-languages/java/java.js +10 -0
  80. data/public/vendor/monaco/vs/basic-languages/javascript/javascript.js +10 -0
  81. data/public/vendor/monaco/vs/basic-languages/julia/julia.js +10 -0
  82. data/public/vendor/monaco/vs/basic-languages/kotlin/kotlin.js +10 -0
  83. data/public/vendor/monaco/vs/basic-languages/less/less.js +11 -0
  84. data/public/vendor/monaco/vs/basic-languages/lexon/lexon.js +10 -0
  85. data/public/vendor/monaco/vs/basic-languages/liquid/liquid.js +10 -0
  86. data/public/vendor/monaco/vs/basic-languages/lua/lua.js +10 -0
  87. data/public/vendor/monaco/vs/basic-languages/m3/m3.js +10 -0
  88. data/public/vendor/monaco/vs/basic-languages/markdown/markdown.js +10 -0
  89. data/public/vendor/monaco/vs/basic-languages/mdx/mdx.js +10 -0
  90. data/public/vendor/monaco/vs/basic-languages/mips/mips.js +10 -0
  91. data/public/vendor/monaco/vs/basic-languages/msdax/msdax.js +10 -0
  92. data/public/vendor/monaco/vs/basic-languages/mysql/mysql.js +10 -0
  93. data/public/vendor/monaco/vs/basic-languages/objective-c/objective-c.js +10 -0
  94. data/public/vendor/monaco/vs/basic-languages/pascal/pascal.js +10 -0
  95. data/public/vendor/monaco/vs/basic-languages/pascaligo/pascaligo.js +10 -0
  96. data/public/vendor/monaco/vs/basic-languages/perl/perl.js +10 -0
  97. data/public/vendor/monaco/vs/basic-languages/pgsql/pgsql.js +10 -0
  98. data/public/vendor/monaco/vs/basic-languages/php/php.js +10 -0
  99. data/public/vendor/monaco/vs/basic-languages/pla/pla.js +10 -0
  100. data/public/vendor/monaco/vs/basic-languages/postiats/postiats.js +10 -0
  101. data/public/vendor/monaco/vs/basic-languages/powerquery/powerquery.js +10 -0
  102. data/public/vendor/monaco/vs/basic-languages/powershell/powershell.js +10 -0
  103. data/public/vendor/monaco/vs/basic-languages/protobuf/protobuf.js +11 -0
  104. data/public/vendor/monaco/vs/basic-languages/pug/pug.js +10 -0
  105. data/public/vendor/monaco/vs/basic-languages/python/python.js +10 -0
  106. data/public/vendor/monaco/vs/basic-languages/qsharp/qsharp.js +10 -0
  107. data/public/vendor/monaco/vs/basic-languages/r/r.js +10 -0
  108. data/public/vendor/monaco/vs/basic-languages/razor/razor.js +10 -0
  109. data/public/vendor/monaco/vs/basic-languages/redis/redis.js +10 -0
  110. data/public/vendor/monaco/vs/basic-languages/redshift/redshift.js +10 -0
  111. data/public/vendor/monaco/vs/basic-languages/restructuredtext/restructuredtext.js +10 -0
  112. data/public/vendor/monaco/vs/basic-languages/ruby/ruby.js +10 -0
  113. data/public/vendor/monaco/vs/basic-languages/rust/rust.js +10 -0
  114. data/public/vendor/monaco/vs/basic-languages/sb/sb.js +10 -0
  115. data/public/vendor/monaco/vs/basic-languages/scala/scala.js +10 -0
  116. data/public/vendor/monaco/vs/basic-languages/scheme/scheme.js +10 -0
  117. data/public/vendor/monaco/vs/basic-languages/scss/scss.js +12 -0
  118. data/public/vendor/monaco/vs/basic-languages/shell/shell.js +10 -0
  119. data/public/vendor/monaco/vs/basic-languages/solidity/solidity.js +10 -0
  120. data/public/vendor/monaco/vs/basic-languages/sophia/sophia.js +10 -0
  121. data/public/vendor/monaco/vs/basic-languages/sparql/sparql.js +10 -0
  122. data/public/vendor/monaco/vs/basic-languages/sql/sql.js +10 -0
  123. data/public/vendor/monaco/vs/basic-languages/st/st.js +10 -0
  124. data/public/vendor/monaco/vs/basic-languages/swift/swift.js +13 -0
  125. data/public/vendor/monaco/vs/basic-languages/systemverilog/systemverilog.js +10 -0
  126. data/public/vendor/monaco/vs/basic-languages/tcl/tcl.js +10 -0
  127. data/public/vendor/monaco/vs/basic-languages/twig/twig.js +10 -0
  128. data/public/vendor/monaco/vs/basic-languages/typescript/typescript.js +10 -0
  129. data/public/vendor/monaco/vs/basic-languages/typespec/typespec.js +10 -0
  130. data/public/vendor/monaco/vs/basic-languages/vb/vb.js +10 -0
  131. data/public/vendor/monaco/vs/basic-languages/wgsl/wgsl.js +307 -0
  132. data/public/vendor/monaco/vs/basic-languages/xml/xml.js +10 -0
  133. data/public/vendor/monaco/vs/basic-languages/yaml/yaml.js +10 -0
  134. data/public/vendor/monaco/vs/editor/editor.main.css +8 -0
  135. data/public/vendor/monaco/vs/editor/editor.main.js +798 -0
  136. data/public/vendor/monaco/vs/language/css/cssMode.js +13 -0
  137. data/public/vendor/monaco/vs/language/css/cssWorker.js +77 -0
  138. data/public/vendor/monaco/vs/language/html/htmlMode.js +13 -0
  139. data/public/vendor/monaco/vs/language/html/htmlWorker.js +454 -0
  140. data/public/vendor/monaco/vs/language/json/jsonMode.js +19 -0
  141. data/public/vendor/monaco/vs/language/json/jsonWorker.js +42 -0
  142. data/public/vendor/monaco/vs/language/typescript/tsMode.js +20 -0
  143. data/public/vendor/monaco/vs/language/typescript/tsWorker.js +51328 -0
  144. data/public/vendor/monaco/vs/loader.js +11 -0
  145. data/public/vendor/monaco/vs/nls.messages.de.js +21 -0
  146. data/public/vendor/monaco/vs/nls.messages.es.js +21 -0
  147. data/public/vendor/monaco/vs/nls.messages.fr.js +19 -0
  148. data/public/vendor/monaco/vs/nls.messages.it.js +19 -0
  149. data/public/vendor/monaco/vs/nls.messages.ja.js +21 -0
  150. data/public/vendor/monaco/vs/nls.messages.ko.js +19 -0
  151. data/public/vendor/monaco/vs/nls.messages.ru.js +21 -0
  152. data/public/vendor/monaco/vs/nls.messages.zh-cn.js +21 -0
  153. data/public/vendor/monaco/vs/nls.messages.zh-tw.js +19 -0
  154. data/public/vendor/xterm/LICENSE +25 -0
  155. data/public/vendor/xterm/addon-fit.js +2 -0
  156. data/public/vendor/xterm/xterm.css +218 -0
  157. data/public/vendor/xterm/xterm.js +2 -0
  158. metadata +318 -0
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ffi"
4
+ require "json"
5
+ require "base64"
6
+ require "securerandom"
7
+ require_relative "../errors"
8
+
9
+ module Chocomint
10
+ module Console
11
+ # コンソールワーカーを「新規コンソール付き」で spawn し、名前付きパイプで
12
+ # 双方向にやり取りするための橋渡し。
13
+ #
14
+ # なぜ名前付きパイプか (重要): ConPTY はコンソール付きプロセスからしか
15
+ # 正しく動かない (chocomint-conpty メモの制約)。CREATE_NEW_CONSOLE で新規
16
+ # コンソールを与える必要があるが、STARTF_USESTDHANDLES で stdout をパイプへ
17
+ # リダイレクトすると新規コンソールとの関連付けが壊れ、ConPTY 出力が 16 バイトの
18
+ # 初期シーケンスで止まる (実測で確認)。そこで stdio は触らず、制御通信は
19
+ # 独立した名前付きパイプで行う。ワーカーはコンソールを完全に保持できる。
20
+ class WorkerProcess
21
+ module Native
22
+ extend FFI::Library
23
+ ffi_lib "kernel32"
24
+ ffi_convention :stdcall
25
+
26
+ class ProcessInformation < FFI::Struct
27
+ layout :hProcess, :pointer, :hThread, :pointer, :dwProcessId, :uint32, :dwThreadId, :uint32
28
+ end
29
+
30
+ class StartupInfoW < FFI::Struct
31
+ layout(
32
+ :cb, :uint32, :lpReserved, :pointer, :lpDesktop, :pointer, :lpTitle, :pointer,
33
+ :dwX, :uint32, :dwY, :uint32, :dwXSize, :uint32, :dwYSize, :uint32,
34
+ :dwXCountChars, :uint32, :dwYCountChars, :uint32, :dwFillAttribute, :uint32,
35
+ :dwFlags, :uint32, :wShowWindow, :uint16, :cbReserved2, :uint16,
36
+ :lpReserved2, :pointer, :hStdInput, :pointer, :hStdOutput, :pointer, :hStdError, :pointer
37
+ )
38
+ end
39
+
40
+ CREATE_NEW_CONSOLE = 0x00000010
41
+ STARTF_USESHOWWINDOW = 0x00000001
42
+ SW_HIDE = 0
43
+ STILL_ACTIVE = 259
44
+
45
+ PIPE_ACCESS_DUPLEX = 0x00000003
46
+ PIPE_TYPE_BYTE = 0x00000000
47
+ PIPE_WAIT = 0x00000000
48
+ PIPE_REJECT_REMOTE_CLIENTS = 0x00000008
49
+ INVALID_HANDLE_VALUE = FFI::Pointer.new(-1)
50
+
51
+ attach_function :CreateNamedPipeW,
52
+ %i[pointer uint32 uint32 uint32 uint32 uint32 uint32 pointer], :pointer
53
+ attach_function :ConnectNamedPipe, %i[pointer pointer], :int32
54
+ attach_function :DisconnectNamedPipe, [:pointer], :int32
55
+ attach_function :CreateProcessW,
56
+ %i[pointer pointer pointer pointer int32 uint32
57
+ pointer pointer pointer pointer], :int32
58
+ attach_function :ReadFile, %i[pointer pointer uint32 pointer pointer], :int32
59
+ attach_function :WriteFile, %i[pointer pointer uint32 pointer pointer], :int32
60
+ attach_function :PeekNamedPipe, %i[pointer pointer uint32 pointer pointer pointer], :int32
61
+ attach_function :GetExitCodeProcess, %i[pointer pointer], :int32
62
+ attach_function :TerminateProcess, %i[pointer uint32], :int32
63
+ attach_function :CloseHandle, [:pointer], :int32
64
+ attach_function :GetLastError, [], :uint32
65
+ end
66
+
67
+ class Error < Chocomint::Error; end
68
+
69
+ def initialize(command:, cols:, rows:, cwd:, ruby_exe: nil, worker_script: nil)
70
+ @command = command
71
+ @cols = cols
72
+ @rows = rows
73
+ @cwd = cwd
74
+ @ruby_exe = ruby_exe || default_ruby_exe
75
+ @worker_script = worker_script || default_worker_script
76
+ # 双方向を 1 本のパイプで共有すると read/write が競合してブロックするため、
77
+ # 方向ごとに 2 本張る (c2w: 親→worker, w2c: worker→親)。
78
+ base = "\\\\.\\pipe\\chocomint-console-#{SecureRandom.hex(8)}"
79
+ @c2w_name = "#{base}-c2w"
80
+ @w2c_name = "#{base}-w2c"
81
+ @open = false
82
+ end
83
+
84
+ def open?
85
+ @open
86
+ end
87
+
88
+ # 名前付きパイプを 2 本作り、ワーカーを新規コンソール付きで spawn して接続を待つ。
89
+ def start
90
+ raise Error, "already started" if @open
91
+
92
+ @c2w = create_named_pipe(@c2w_name)
93
+ @w2c = create_named_pipe(@w2c_name)
94
+ spawn_worker
95
+ # ワーカーが両パイプに接続してくるのを待つ (順序はワーカーの open と一致させる)。
96
+ Native.ConnectNamedPipe(@c2w, nil)
97
+ Native.ConnectNamedPipe(@w2c, nil)
98
+ @open = true
99
+ self
100
+ end
101
+
102
+ def send_message(hash)
103
+ write_bytes("#{JSON.generate(hash)}\n".b)
104
+ end
105
+
106
+ def send_input(bytes)
107
+ send_message("type" => "input", "data" => Base64.strict_encode64(bytes.b))
108
+ end
109
+
110
+ def send_resize(cols, rows)
111
+ send_message("type" => "resize", "cols" => cols, "rows" => rows)
112
+ end
113
+
114
+ # ワーカーからの JSON 行を 1 つ読む。無ければ nil (ノンブロッキング)。
115
+ def read_message
116
+ line = read_line
117
+ return nil if line.nil? || line.empty?
118
+
119
+ JSON.parse(line)
120
+ rescue JSON::ParserError
121
+ nil
122
+ end
123
+
124
+ def alive?
125
+ return false unless @proc_handle
126
+
127
+ code = FFI::MemoryPointer.new(:uint32)
128
+ return false if Native.GetExitCodeProcess(@proc_handle, code).zero?
129
+
130
+ code.read(:uint32) == Native::STILL_ACTIVE
131
+ end
132
+
133
+ def close
134
+ return unless @open
135
+
136
+ Native.TerminateProcess(@proc_handle, 0) if alive?
137
+ [@c2w, @w2c].each do |h|
138
+ next unless h
139
+
140
+ Native.DisconnectNamedPipe(h)
141
+ Native.CloseHandle(h)
142
+ end
143
+ Native.CloseHandle(@proc_handle) if @proc_handle
144
+ @open = false
145
+ nil
146
+ end
147
+
148
+ private
149
+
150
+ def default_ruby_exe
151
+ require "rbconfig"
152
+ File.join(RbConfig::CONFIG["bindir"], RbConfig::CONFIG["ruby_install_name"])
153
+ end
154
+
155
+ def default_worker_script
156
+ File.expand_path("../../../bin/chocomint-console-worker", __dir__)
157
+ end
158
+
159
+ def create_named_pipe(name)
160
+ handle = Native.CreateNamedPipeW(
161
+ utf16(name),
162
+ Native::PIPE_ACCESS_DUPLEX,
163
+ Native::PIPE_TYPE_BYTE | Native::PIPE_WAIT | Native::PIPE_REJECT_REMOTE_CLIENTS,
164
+ 1, 65_536, 65_536, 0, nil
165
+ )
166
+ if handle == Native::INVALID_HANDLE_VALUE || handle.null?
167
+ raise Error, "CreateNamedPipeW failed (#{Native.GetLastError})"
168
+ end
169
+
170
+ handle
171
+ end
172
+
173
+ def spawn_worker
174
+ si = Native::StartupInfoW.new
175
+ si[:cb] = Native::StartupInfoW.size
176
+ si[:dwFlags] = Native::STARTF_USESHOWWINDOW
177
+ si[:wShowWindow] = Native::SW_HIDE
178
+ pi = Native::ProcessInformation.new
179
+
180
+ cmd_ptr = utf16(build_command_line)
181
+ cwd_ptr = @cwd ? utf16(@cwd) : nil
182
+
183
+ ok = Native.CreateProcessW(
184
+ nil, cmd_ptr, nil, nil, 0, # stdio は継承しない (コンソールを汚さない)
185
+ Native::CREATE_NEW_CONSOLE, # ConPTY が動くための新規コンソール
186
+ nil, cwd_ptr, si.pointer, pi.pointer
187
+ )
188
+ raise Error, "CreateProcessW worker failed (#{Native.GetLastError})" if ok.zero?
189
+
190
+ @proc_handle = pi[:hProcess]
191
+ Native.CloseHandle(pi[:hThread])
192
+ end
193
+
194
+ # ruby worker.rb --c2w <name> --w2c <name> --cols .. --rows .. --cwd .. -- <shell>
195
+ def build_command_line
196
+ parts = [quote(@ruby_exe), quote(@worker_script),
197
+ "--c2w", quote(@c2w_name), "--w2c", quote(@w2c_name),
198
+ "--cols", @cols.to_s, "--rows", @rows.to_s]
199
+ parts += ["--cwd", quote(@cwd)] if @cwd
200
+ parts += ["--", @command]
201
+ parts.join(" ")
202
+ end
203
+
204
+ def quote(str) = %("#{str}")
205
+
206
+ def utf16(str)
207
+ w = str.encode("UTF-16LE").b + "\x00\x00".b
208
+ ptr = FFI::MemoryPointer.new(:uint8, w.bytesize)
209
+ ptr.put_bytes(0, w)
210
+ ptr
211
+ end
212
+
213
+ # 親→worker は c2w パイプへ書く。
214
+ def write_bytes(data)
215
+ buf = FFI::MemoryPointer.new(:uint8, data.bytesize)
216
+ buf.put_bytes(0, data)
217
+ written = FFI::MemoryPointer.new(:uint32)
218
+ ok = Native.WriteFile(@c2w, buf, data.bytesize, written, nil)
219
+ raise Error, "WriteFile pipe (#{Native.GetLastError})" if ok.zero?
220
+
221
+ written.read(:uint32)
222
+ end
223
+
224
+ def read_line
225
+ @rbuf ||= +""
226
+ loop do
227
+ if (idx = @rbuf.index("\n"))
228
+ return @rbuf.slice!(0..idx).chomp
229
+ end
230
+ chunk = read_available_bytes
231
+ return nil if chunk.nil? || chunk.empty?
232
+
233
+ @rbuf << chunk
234
+ end
235
+ end
236
+
237
+ # worker→親は w2c パイプから読む。
238
+ def read_available_bytes(max = 65_536)
239
+ avail = FFI::MemoryPointer.new(:uint32)
240
+ return nil if Native.PeekNamedPipe(@w2c, nil, 0, nil, avail, nil).zero?
241
+
242
+ n = avail.read(:uint32)
243
+ return "".b if n.zero?
244
+
245
+ want = [n, max].min
246
+ buf = FFI::MemoryPointer.new(:uint8, want)
247
+ read = FFI::MemoryPointer.new(:uint32)
248
+ return nil if Native.ReadFile(@w2c, buf, want, read, nil).zero?
249
+
250
+ buf.get_bytes(0, read.read(:uint32))
251
+ end
252
+ end
253
+ end
254
+ end
@@ -0,0 +1,397 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+ require "fileutils"
6
+
7
+ module Chocomint
8
+ # /edit AI エディタの Git 操作ハンドラ群 (Server に include して使う)。
9
+ #
10
+ # 提供機能:
11
+ # GET /edit/git/status 初期化有無・ブランチ名・ステージ済み/未ステージのファイル一覧
12
+ # POST /edit/git/init git init
13
+ # POST /edit/git/stage ファイルをステージ (git add)
14
+ # POST /edit/git/unstage ステージ取り消し (git restore --staged)
15
+ # POST /edit/git/stage_all すべての変更をステージ (git add -A)
16
+ # POST /edit/git/unstage_all すべてのステージを取り消し (git restore --staged .)
17
+ # POST /edit/git/discard 変更を破棄 (追跡済みは checkout、未追跡は削除)
18
+ # POST /edit/git/commit コミット
19
+ # POST /edit/git/push push (リモート未設定なら reason: "no_remote" 付き 409 を返す)
20
+ # POST /edit/git/remote origin の URL を設定して push (未設定/差し替えの両対応)
21
+ # GET /edit/git/branches ブランチ一覧 (現在のブランチも含む)
22
+ # POST /edit/git/checkout ブランチ切り替え/新規作成
23
+ #
24
+ # すべて edit_root (workspace ルート) を cwd にして git コマンドを実行する。
25
+ # workspace が git 管理下でない場合は status で初期化を促す。
26
+ module EditGitHandlers
27
+ # ---- ステータス ---------------------------------------------------------
28
+
29
+ def handle_edit_git_status(req, res)
30
+ return edit_method_guard(res) unless req.request_method == "GET"
31
+
32
+ unless git_repo?
33
+ return edit_json(res, 200, "initialized" => false)
34
+ end
35
+
36
+ branch = git_current_branch
37
+ # --untracked-files=all: 未追跡ディレクトリを 1 エントリにまとめず中のファイルを
38
+ # 個別に列挙させる。無指定だと "logs/" のようにディレクトリ単位でしか返らず、
39
+ # + ボタンでファイル単位のステージができなくなる。
40
+ porcelain = git_capture(%w[status --porcelain=v1 --untracked-files=all])
41
+ staged, unstaged = parse_git_status(porcelain)
42
+
43
+ edit_json(res, 200,
44
+ "initialized" => true,
45
+ "branch" => branch,
46
+ "staged" => staged,
47
+ "unstaged" => unstaged,
48
+ "remote_url" => git_remote_url)
49
+ rescue Chocomint::Error => e
50
+ edit_json(res, 500, "error" => e.message)
51
+ end
52
+
53
+ # git status --porcelain=v1 の各行 (XY PATH) をステージ済み/未ステージに振り分ける。
54
+ # X: インデックス(ステージ)の状態、Y: 作業ツリーの状態。
55
+ # "??" (未追跡) は unstaged 扱いにする。
56
+ def parse_git_status(lines)
57
+ staged = []
58
+ unstaged = []
59
+ lines.each do |line|
60
+ next if line.length < 4
61
+
62
+ x = line[0]
63
+ y = line[1]
64
+ path = line[3..].to_s.tr("\\", "/")
65
+ # rename は "R old -> new" の形式。表示・操作対象は new 側のパス。
66
+ path = path.split(" -> ").last if path.include?(" -> ")
67
+
68
+ staged << { "path" => path, "status" => x } if x != " " && x != "?"
69
+ unstaged << { "path" => path, "status" => y } if y != " " || x == "?"
70
+ end
71
+ [staged, unstaged]
72
+ end
73
+
74
+ # ---- 初期化 ---------------------------------------------------------------
75
+
76
+ def handle_edit_git_init(req, res)
77
+ return edit_method_guard(res) unless req.request_method == "POST"
78
+
79
+ if git_repo?
80
+ return edit_json(res, 400, "error" => "既に git 管理下です")
81
+ end
82
+
83
+ out, err, status = git_run(%w[init])
84
+ return edit_json(res, 500, "error" => err.empty? ? out : err) unless status.success?
85
+
86
+ edit_json(res, 200, "initialized" => true, "branch" => git_current_branch)
87
+ rescue Chocomint::Error => e
88
+ edit_json(res, 500, "error" => e.message)
89
+ end
90
+
91
+ # ---- ステージ / 取り消し ---------------------------------------------------
92
+
93
+ def handle_edit_git_stage(req, res)
94
+ return edit_method_guard(res) unless req.request_method == "POST"
95
+
96
+ with_git(res) do
97
+ require_git_repo!
98
+ paths = git_body_paths(req)
99
+ out, err, status = git_run(["add", "--"] + paths)
100
+ raise Chocomint::Error, (err.empty? ? out : err) unless status.success?
101
+
102
+ edit_json(res, 200, "staged" => paths)
103
+ end
104
+ end
105
+
106
+ def handle_edit_git_unstage(req, res)
107
+ return edit_method_guard(res) unless req.request_method == "POST"
108
+
109
+ with_git(res) do
110
+ require_git_repo!
111
+ paths = git_body_paths(req)
112
+ out, err, status = unstage_paths(paths)
113
+ raise Chocomint::Error, (err.empty? ? out : err) unless status.success?
114
+
115
+ edit_json(res, 200, "unstaged" => paths)
116
+ end
117
+ end
118
+
119
+ def handle_edit_git_stage_all(req, res)
120
+ return edit_method_guard(res) unless req.request_method == "POST"
121
+
122
+ with_git(res) do
123
+ require_git_repo!
124
+ out, err, status = git_run(%w[add -A])
125
+ raise Chocomint::Error, (err.empty? ? out : err) unless status.success?
126
+
127
+ edit_json(res, 200, "staged_all" => true)
128
+ end
129
+ end
130
+
131
+ def handle_edit_git_unstage_all(req, res)
132
+ return edit_method_guard(res) unless req.request_method == "POST"
133
+
134
+ with_git(res) do
135
+ require_git_repo!
136
+ out, err, status = unstage_paths(%w[.])
137
+ raise Chocomint::Error, (err.empty? ? out : err) unless status.success?
138
+
139
+ edit_json(res, 200, "unstaged_all" => true)
140
+ end
141
+ end
142
+
143
+ # ---- 変更の破棄 ---------------------------------------------------------------
144
+ #
145
+ # 追跡済みファイルの変更は `git restore` で元に戻し、未追跡ファイルはワークツリーから
146
+ # 削除する (git 管理下に無いため restore では戻せない)。ディレクトリ丸ごとの未追跡も
147
+ # 同様に削除する。ステージ済みの変更はまず取り消してから破棄する (add 済みでも作業ツリーを戻す)。
148
+ def handle_edit_git_discard(req, res)
149
+ return edit_method_guard(res) unless req.request_method == "POST"
150
+
151
+ with_git(res) do
152
+ require_git_repo!
153
+ paths = git_body_paths(req)
154
+ discard_paths(paths)
155
+ edit_json(res, 200, "discarded" => paths)
156
+ end
157
+ end
158
+
159
+ def discard_paths(paths)
160
+ tracked, untracked = paths.partition { |p| git_tracked?(p) }
161
+
162
+ unless tracked.empty?
163
+ unstage_paths(tracked)
164
+ _out, err, status = git_run(["restore", "--"] + tracked)
165
+ raise Chocomint::Error, (err.empty? ? "変更の破棄に失敗しました" : err) unless status.success?
166
+ end
167
+
168
+ untracked.each do |p|
169
+ abs = @path_guard.resolve(p)
170
+ FileUtils.rm_rf(abs)
171
+ end
172
+ end
173
+
174
+ # ステージ済みの変更を取り消す。`git restore --staged` は初回コミット前
175
+ # (HEAD が無い) だと "fatal: could not resolve HEAD" で失敗するため、
176
+ # その場合は index をリセットする `git reset` にフォールバックする。
177
+ def unstage_paths(paths)
178
+ out, err, status = git_run(["restore", "--staged", "--"] + paths)
179
+ return [out, err, status] if status.success?
180
+
181
+ git_run(["reset", "--"] + paths)
182
+ end
183
+
184
+ def git_tracked?(path)
185
+ _out, _err, status = git_run(["ls-files", "--error-unmatch", "--", path])
186
+ status.success?
187
+ end
188
+
189
+ # ---- コミット ---------------------------------------------------------------
190
+
191
+ def handle_edit_git_commit(req, res)
192
+ return edit_method_guard(res) unless req.request_method == "POST"
193
+
194
+ with_git(res) do
195
+ require_git_repo!
196
+ body = JSON.parse(req.body.to_s)
197
+ message = body["message"].to_s.strip
198
+ raise Chocomint::Error, "コミットメッセージが必要です" if message.empty?
199
+
200
+ out, err, status = git_run(["commit", "-m", message])
201
+ raise Chocomint::Error, (err.empty? ? out : err) unless status.success?
202
+
203
+ edit_json(res, 200, "committed" => true, "branch" => git_current_branch)
204
+ end
205
+ end
206
+
207
+ # ---- push ---------------------------------------------------------------
208
+
209
+ def handle_edit_git_push(req, res)
210
+ return edit_method_guard(res) unless req.request_method == "POST"
211
+
212
+ unless git_repo?
213
+ return edit_json(res, 400, "error" => "git 管理下ではありません")
214
+ end
215
+
216
+ out, err, status = git_run(%w[push])
217
+ return edit_json(res, 200, "pushed" => true, "output" => (err.empty? ? out : err)) if status.success?
218
+
219
+ message = err.empty? ? out : err
220
+ # リモート/上流ブランチが未設定だと push は失敗する。フロントはこの reason で
221
+ # 「リモート URL を入力させるダイアログ」に切り替える (エラーダイアログではなく)。
222
+ if push_target_missing?(message)
223
+ return edit_json(res, 409, "error" => message, "reason" => "no_remote")
224
+ end
225
+
226
+ edit_json(res, 400, "error" => message)
227
+ rescue JSON::ParserError => e
228
+ edit_json(res, 400, "error" => "invalid JSON: #{e.message}")
229
+ rescue Chocomint::Error => e
230
+ edit_json(res, 500, "error" => e.message)
231
+ end
232
+
233
+ # push 先が無いことを示すエラー文言か判定する (リモート未設定 / 上流ブランチ未設定の両方)。
234
+ def push_target_missing?(message)
235
+ message.include?("No configured push destination") ||
236
+ message.include?("has no upstream branch")
237
+ end
238
+
239
+ # ---- リモート ---------------------------------------------------------------
240
+
241
+ def handle_edit_git_remote_set(req, res)
242
+ return edit_method_guard(res) unless req.request_method == "POST"
243
+
244
+ with_git(res) do
245
+ require_git_repo!
246
+ body = JSON.parse(req.body.to_s)
247
+ url = body["url"].to_s.strip
248
+ raise Chocomint::Error, "リモート URL を入力してください" if url.empty?
249
+
250
+ # 既に origin が設定されていれば URL を差し替え、無ければ新規に追加する。
251
+ if git_remote_url
252
+ out, err, status = git_run(%w[remote set-url origin] + [url])
253
+ else
254
+ out, err, status = git_run(%w[remote add origin] + [url])
255
+ end
256
+ raise Chocomint::Error, (err.empty? ? out : err) unless status.success?
257
+
258
+ # 上流ブランチも合わせて設定しつつ push する (初回 push は -u が無いと
259
+ # "has no upstream branch" で失敗するため)。
260
+ branch = git_current_branch
261
+ out, err, status = git_run(["push", "-u", "origin", branch])
262
+ raise Chocomint::Error, (err.empty? ? out : err) unless status.success?
263
+
264
+ edit_json(res, 200, "pushed" => true, "remote_url" => url, "output" => (err.empty? ? out : err))
265
+ end
266
+ end
267
+
268
+ # ---- ブランチ ---------------------------------------------------------------
269
+
270
+ def handle_edit_git_branches(req, res)
271
+ return edit_method_guard(res) unless req.request_method == "GET"
272
+
273
+ unless git_repo?
274
+ return edit_json(res, 400, "error" => "git 管理下ではありません")
275
+ end
276
+
277
+ out = git_capture(%w[branch --list])
278
+ branches = out.map { |l| l.sub(/\A\*?\s*/, "").strip }.reject(&:empty?)
279
+ current = git_current_branch
280
+ # コミットが一つも無いブランチ (init 直後など) は `git branch --list` に
281
+ # 現れないため、現在のブランチが漏れていれば一覧に加えて必ず選択肢に見せる。
282
+ branches.unshift(current) if current && !branches.include?(current)
283
+ edit_json(res, 200, "branches" => branches, "current" => current)
284
+ rescue Chocomint::Error => e
285
+ edit_json(res, 500, "error" => e.message)
286
+ end
287
+
288
+ def handle_edit_git_checkout(req, res)
289
+ return edit_method_guard(res) unless req.request_method == "POST"
290
+
291
+ with_git(res) do
292
+ require_git_repo!
293
+ body = JSON.parse(req.body.to_s)
294
+ branch = body["branch"].to_s.strip
295
+ create = !!body["create"]
296
+ raise Chocomint::Error, "branch required" if branch.empty?
297
+
298
+ args = create ? ["checkout", "-b", branch] : ["checkout", branch]
299
+ out, err, status = git_run(args)
300
+ unless status.success?
301
+ message = err.empty? ? out : err
302
+ # ブランチ参照が見つからない場合、原因は「名前が間違っている」か
303
+ # 「切替先にまだコミットが一つも無く Git 上に実体化していない」のどちらか
304
+ # (エラー文言だけでは判別できない) なので、両方の可能性を伝える。
305
+ if !create && (message.include?("invalid reference") || message.include?("did not match"))
306
+ message += "\n(ブランチ名の誤り、またはコミットが一つも無い空のブランチのため" \
307
+ "切り替えられていない可能性があります。空のブランチは最初のコミットを" \
308
+ "するまで Git 上に実体化しません。)"
309
+ end
310
+ raise Chocomint::Error, message
311
+ end
312
+
313
+ edit_json(res, 200, "branch" => git_current_branch)
314
+ end
315
+ end
316
+
317
+ # ---- 補助 ---------------------------------------------------------------
318
+
319
+ # Open3::Status 互換 (success? だけ呼べれば良い) の失敗ダミー。
320
+ # git コマンド自体が見つからない (Errno::ENOENT) 場合に使う。
321
+ class GitCommandNotFoundStatus
322
+ def success? = false
323
+ end
324
+ GIT_NOT_FOUND = GitCommandNotFoundStatus.new.freeze
325
+
326
+ # edit_root 自身が git リポジトリのトップレベルである場合のみ true を返す。
327
+ # workspace が祖先ディレクトリの .git を継承しているだけ (workspace 自体は
328
+ # 管理下に無い) だと git コマンドはその祖先リポジトリに対して動いてしまう
329
+ # ため、show-toplevel が edit_root と一致するかまで確認する。
330
+ def git_repo?
331
+ out, _err, status = git_run(%w[rev-parse --show-toplevel])
332
+ return false unless status.success?
333
+
334
+ same_path?(out.strip, edit_root)
335
+ end
336
+
337
+ # git の出力 (常に "/" 区切り) と Ruby 側の絶対パスを比較する。
338
+ # Windows はドライブレターの大小文字揺れがあるため大文字小文字を無視する。
339
+ def same_path?(git_path, ruby_path)
340
+ normalize_path(git_path) == normalize_path(ruby_path)
341
+ end
342
+
343
+ def normalize_path(path)
344
+ File.expand_path(path).tr("\\", "/").downcase
345
+ end
346
+
347
+ def git_current_branch
348
+ out, _err, status = git_run(%w[branch --show-current])
349
+ status.success? ? out.strip : nil
350
+ end
351
+
352
+ # origin の URL (未設定なら nil)。
353
+ def git_remote_url
354
+ out, _err, status = git_run(%w[remote get-url origin])
355
+ status.success? ? out.strip : nil
356
+ end
357
+
358
+ # git コマンドを edit_root で実行し [stdout, stderr, Process::Status] を返す。
359
+ def git_run(args)
360
+ Open3.capture3("git", *args, chdir: edit_root)
361
+ rescue Errno::ENOENT
362
+ ["", "git コマンドが見つかりません", GIT_NOT_FOUND]
363
+ end
364
+
365
+ # 成功時の stdout を行配列で返す (失敗時は空配列)。
366
+ def git_capture(args)
367
+ out, _err, status = git_run(args)
368
+ status.success? ? out.each_line.map(&:chomp) : []
369
+ end
370
+
371
+ # JSON ボディから "paths" (配列) または "path" (単一) を取り出す。
372
+ def git_body_paths(req)
373
+ body = JSON.parse(req.body.to_s)
374
+ paths = Array(body["paths"] || body["path"]).map(&:to_s).reject(&:empty?)
375
+ raise Chocomint::Error, "path(s) required" if paths.empty?
376
+
377
+ paths
378
+ end
379
+
380
+ # edit_root がリポジトリのトップレベルでなければ拒否する。init/checkout 以外の
381
+ # 全操作の先頭で呼び、祖先ディレクトリの .git を誤って操作しないようにする。
382
+ def require_git_repo!
383
+ raise Chocomint::Error, "git 管理下ではありません" unless git_repo?
384
+ end
385
+
386
+ # Git 操作系ハンドラ共通の例外→HTTP 変換。
387
+ def with_git(res)
388
+ yield
389
+ rescue JSON::ParserError => e
390
+ edit_json(res, 400, "error" => "invalid JSON: #{e.message}")
391
+ rescue Chocomint::Error => e
392
+ edit_json(res, 400, "error" => e.message)
393
+ rescue SystemCallError => e
394
+ edit_json(res, 500, "error" => e.message)
395
+ end
396
+ end
397
+ end