Module: Chocomint::EditGitHandlers

Included in:
Server
Defined in:
lib/chocomint/edit_git_handlers.rb

Overview

/edit AI エディタの Git 操作ハンドラ群 (Server に include して使う)。

提供機能:

GET  /edit/git/status    初期化有無・ブランチ名・ステージ済み/未ステージのファイル一覧
POST /edit/git/init      git init
POST /edit/git/stage       ファイルをステージ (git add)
POST /edit/git/unstage     ステージ取り消し (git restore --staged)
POST /edit/git/stage_all   すべての変更をステージ (git add -A)
POST /edit/git/unstage_all すべてのステージを取り消し (git restore --staged .)
POST /edit/git/discard     変更を破棄 (追跡済みは checkout、未追跡は削除)
POST /edit/git/commit    コミット
POST /edit/git/push      push (リモート未設定なら reason: "no_remote" 付き 409 を返す)
POST /edit/git/remote    origin の URL を設定して push (未設定/差し替えの両対応)
GET  /edit/git/branches  ブランチ一覧 (現在のブランチも含む)
POST /edit/git/checkout  ブランチ切り替え/新規作成

すべて edit_root (workspace ルート) を cwd にして git コマンドを実行する。 workspace が git 管理下でない場合は status で初期化を促す。

Defined Under Namespace

Classes: GitCommandNotFoundStatus

Constant Summary collapse

GIT_NOT_FOUND =
GitCommandNotFoundStatus.new.freeze

Instance Method Summary collapse

Instance Method Details

#discard_paths(paths) ⇒ Object



159
160
161
162
163
164
165
166
167
168
169
170
171
172
# File 'lib/chocomint/edit_git_handlers.rb', line 159

def discard_paths(paths)
  tracked, untracked = paths.partition { |p| git_tracked?(p) }

  unless tracked.empty?
    unstage_paths(tracked)
    _out, err, status = git_run(["restore", "--"] + tracked)
    raise Chocomint::Error, (err.empty? ? "変更の破棄に失敗しました" : err) unless status.success?
  end

  untracked.each do |p|
    abs = @path_guard.resolve(p)
    FileUtils.rm_rf(abs)
  end
end

#git_body_paths(req) ⇒ Object

JSON ボディから "paths" (配列) または "path" (単一) を取り出す。

Raises:



372
373
374
375
376
377
378
# File 'lib/chocomint/edit_git_handlers.rb', line 372

def git_body_paths(req)
  body = JSON.parse(req.body.to_s)
  paths = Array(body["paths"] || body["path"]).map(&:to_s).reject(&:empty?)
  raise Chocomint::Error, "path(s) required" if paths.empty?

  paths
end

#git_capture(args) ⇒ Object

成功時の stdout を行配列で返す (失敗時は空配列)。



366
367
368
369
# File 'lib/chocomint/edit_git_handlers.rb', line 366

def git_capture(args)
  out, _err, status = git_run(args)
  status.success? ? out.each_line.map(&:chomp) : []
end

#git_current_branchObject



347
348
349
350
# File 'lib/chocomint/edit_git_handlers.rb', line 347

def git_current_branch
  out, _err, status = git_run(%w[branch --show-current])
  status.success? ? out.strip : nil
end

#git_remote_urlObject

origin の URL (未設定なら nil)。



353
354
355
356
# File 'lib/chocomint/edit_git_handlers.rb', line 353

def git_remote_url
  out, _err, status = git_run(%w[remote get-url origin])
  status.success? ? out.strip : nil
end

#git_repo?Boolean

edit_root 自身が git リポジトリのトップレベルである場合のみ true を返す。 workspace が祖先ディレクトリの .git を継承しているだけ (workspace 自体は 管理下に無い) だと git コマンドはその祖先リポジトリに対して動いてしまう ため、show-toplevel が edit_root と一致するかまで確認する。

Returns:

  • (Boolean)


330
331
332
333
334
335
# File 'lib/chocomint/edit_git_handlers.rb', line 330

def git_repo?
  out, _err, status = git_run(%w[rev-parse --show-toplevel])
  return false unless status.success?

  same_path?(out.strip, edit_root)
end

#git_run(args) ⇒ Object

git コマンドを edit_root で実行し [stdout, stderr, Process::Status] を返す。



359
360
361
362
363
# File 'lib/chocomint/edit_git_handlers.rb', line 359

def git_run(args)
  Open3.capture3("git", *args, chdir: edit_root)
rescue Errno::ENOENT
  ["", "git コマンドが見つかりません", GIT_NOT_FOUND]
end

#git_tracked?(path) ⇒ Boolean

Returns:

  • (Boolean)


184
185
186
187
# File 'lib/chocomint/edit_git_handlers.rb', line 184

def git_tracked?(path)
  _out, _err, status = git_run(["ls-files", "--error-unmatch", "--", path])
  status.success?
end

#handle_edit_git_branches(req, res) ⇒ Object

---- ブランチ ---------------------------------------------------------------



270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
# File 'lib/chocomint/edit_git_handlers.rb', line 270

def handle_edit_git_branches(req, res)
  return edit_method_guard(res) unless req.request_method == "GET"

  unless git_repo?
    return edit_json(res, 400, "error" => "git 管理下ではありません")
  end

  out = git_capture(%w[branch --list])
  branches = out.map { |l| l.sub(/\A\*?\s*/, "").strip }.reject(&:empty?)
  current = git_current_branch
  # コミットが一つも無いブランチ (init 直後など) は `git branch --list` に
  # 現れないため、現在のブランチが漏れていれば一覧に加えて必ず選択肢に見せる。
  branches.unshift(current) if current && !branches.include?(current)
  edit_json(res, 200, "branches" => branches, "current" => current)
rescue Chocomint::Error => e
  edit_json(res, 500, "error" => e.message)
end

#handle_edit_git_checkout(req, res) ⇒ Object



288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
# File 'lib/chocomint/edit_git_handlers.rb', line 288

def handle_edit_git_checkout(req, res)
  return edit_method_guard(res) unless req.request_method == "POST"

  with_git(res) do
    require_git_repo!
    body = JSON.parse(req.body.to_s)
    branch = body["branch"].to_s.strip
    create = !!body["create"]
    raise Chocomint::Error, "branch required" if branch.empty?

    args = create ? ["checkout", "-b", branch] : ["checkout", branch]
    out, err, status = git_run(args)
    unless status.success?
      message = err.empty? ? out : err
      # ブランチ参照が見つからない場合、原因は「名前が間違っている」か
      # 「切替先にまだコミットが一つも無く Git 上に実体化していない」のどちらか
      # (エラー文言だけでは判別できない) なので、両方の可能性を伝える。
      if !create && (message.include?("invalid reference") || message.include?("did not match"))
        message += "\n(ブランチ名の誤り、またはコミットが一つも無い空のブランチのため" \
                    "切り替えられていない可能性があります。空のブランチは最初のコミットを" \
                    "するまで Git 上に実体化しません。)"
      end
      raise Chocomint::Error, message
    end

    edit_json(res, 200, "branch" => git_current_branch)
  end
end

#handle_edit_git_commit(req, res) ⇒ Object

---- コミット ---------------------------------------------------------------



191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
# File 'lib/chocomint/edit_git_handlers.rb', line 191

def handle_edit_git_commit(req, res)
  return edit_method_guard(res) unless req.request_method == "POST"

  with_git(res) do
    require_git_repo!
    body = JSON.parse(req.body.to_s)
    message = body["message"].to_s.strip
    raise Chocomint::Error, "コミットメッセージが必要です" if message.empty?

    out, err, status = git_run(["commit", "-m", message])
    raise Chocomint::Error, (err.empty? ? out : err) unless status.success?

    edit_json(res, 200, "committed" => true, "branch" => git_current_branch)
  end
end

#handle_edit_git_discard(req, res) ⇒ Object

---- 変更の破棄 ---------------------------------------------------------------

追跡済みファイルの変更は git restore で元に戻し、未追跡ファイルはワークツリーから 削除する (git 管理下に無いため restore では戻せない)。ディレクトリ丸ごとの未追跡も 同様に削除する。ステージ済みの変更はまず取り消してから破棄する (add 済みでも作業ツリーを戻す)。



148
149
150
151
152
153
154
155
156
157
# File 'lib/chocomint/edit_git_handlers.rb', line 148

def handle_edit_git_discard(req, res)
  return edit_method_guard(res) unless req.request_method == "POST"

  with_git(res) do
    require_git_repo!
    paths = git_body_paths(req)
    discard_paths(paths)
    edit_json(res, 200, "discarded" => paths)
  end
end

#handle_edit_git_init(req, res) ⇒ Object

---- 初期化 ---------------------------------------------------------------



76
77
78
79
80
81
82
83
84
85
86
87
88
89
# File 'lib/chocomint/edit_git_handlers.rb', line 76

def handle_edit_git_init(req, res)
  return edit_method_guard(res) unless req.request_method == "POST"

  if git_repo?
    return edit_json(res, 400, "error" => "既に git 管理下です")
  end

  out, err, status = git_run(%w[init])
  return edit_json(res, 500, "error" => err.empty? ? out : err) unless status.success?

  edit_json(res, 200, "initialized" => true, "branch" => git_current_branch)
rescue Chocomint::Error => e
  edit_json(res, 500, "error" => e.message)
end

#handle_edit_git_push(req, res) ⇒ Object

---- push ---------------------------------------------------------------



209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
# File 'lib/chocomint/edit_git_handlers.rb', line 209

def handle_edit_git_push(req, res)
  return edit_method_guard(res) unless req.request_method == "POST"

  unless git_repo?
    return edit_json(res, 400, "error" => "git 管理下ではありません")
  end

  out, err, status = git_run(%w[push])
  return edit_json(res, 200, "pushed" => true, "output" => (err.empty? ? out : err)) if status.success?

  message = err.empty? ? out : err
  # リモート/上流ブランチが未設定だと push は失敗する。フロントはこの reason で
  # 「リモート URL を入力させるダイアログ」に切り替える (エラーダイアログではなく)。
  if push_target_missing?(message)
    return edit_json(res, 409, "error" => message, "reason" => "no_remote")
  end

  edit_json(res, 400, "error" => message)
rescue JSON::ParserError => e
  edit_json(res, 400, "error" => "invalid JSON: #{e.message}")
rescue Chocomint::Error => e
  edit_json(res, 500, "error" => e.message)
end

#handle_edit_git_remote_set(req, res) ⇒ Object

---- リモート ---------------------------------------------------------------



241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
# File 'lib/chocomint/edit_git_handlers.rb', line 241

def handle_edit_git_remote_set(req, res)
  return edit_method_guard(res) unless req.request_method == "POST"

  with_git(res) do
    require_git_repo!
    body = JSON.parse(req.body.to_s)
    url = body["url"].to_s.strip
    raise Chocomint::Error, "リモート URL を入力してください" if url.empty?

    # 既に origin が設定されていれば URL を差し替え、無ければ新規に追加する。
    if git_remote_url
      out, err, status = git_run(%w[remote set-url origin] + [url])
    else
      out, err, status = git_run(%w[remote add origin] + [url])
    end
    raise Chocomint::Error, (err.empty? ? out : err) unless status.success?

    # 上流ブランチも合わせて設定しつつ push する (初回 push は -u が無いと
    # "has no upstream branch" で失敗するため)。
    branch = git_current_branch
    out, err, status = git_run(["push", "-u", "origin", branch])
    raise Chocomint::Error, (err.empty? ? out : err) unless status.success?

    edit_json(res, 200, "pushed" => true, "remote_url" => url, "output" => (err.empty? ? out : err))
  end
end

#handle_edit_git_stage(req, res) ⇒ Object

---- ステージ / 取り消し ---------------------------------------------------



93
94
95
96
97
98
99
100
101
102
103
104
# File 'lib/chocomint/edit_git_handlers.rb', line 93

def handle_edit_git_stage(req, res)
  return edit_method_guard(res) unless req.request_method == "POST"

  with_git(res) do
    require_git_repo!
    paths = git_body_paths(req)
    out, err, status = git_run(["add", "--"] + paths)
    raise Chocomint::Error, (err.empty? ? out : err) unless status.success?

    edit_json(res, 200, "staged" => paths)
  end
end

#handle_edit_git_stage_all(req, res) ⇒ Object



119
120
121
122
123
124
125
126
127
128
129
# File 'lib/chocomint/edit_git_handlers.rb', line 119

def handle_edit_git_stage_all(req, res)
  return edit_method_guard(res) unless req.request_method == "POST"

  with_git(res) do
    require_git_repo!
    out, err, status = git_run(%w[add -A])
    raise Chocomint::Error, (err.empty? ? out : err) unless status.success?

    edit_json(res, 200, "staged_all" => true)
  end
end

#handle_edit_git_status(req, res) ⇒ Object

---- ステータス ---------------------------------------------------------



29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
# File 'lib/chocomint/edit_git_handlers.rb', line 29

def handle_edit_git_status(req, res)
  return edit_method_guard(res) unless req.request_method == "GET"

  unless git_repo?
    return edit_json(res, 200, "initialized" => false)
  end

  branch = git_current_branch
  # --untracked-files=all: 未追跡ディレクトリを 1 エントリにまとめず中のファイルを
  # 個別に列挙させる。無指定だと "logs/" のようにディレクトリ単位でしか返らず、
  # + ボタンでファイル単位のステージができなくなる。
  porcelain = git_capture(%w[status --porcelain=v1 --untracked-files=all])
  staged, unstaged = parse_git_status(porcelain)

  edit_json(res, 200,
    "initialized" => true,
    "branch" => branch,
    "staged" => staged,
    "unstaged" => unstaged,
    "remote_url" => git_remote_url)
rescue Chocomint::Error => e
  edit_json(res, 500, "error" => e.message)
end

#handle_edit_git_unstage(req, res) ⇒ Object



106
107
108
109
110
111
112
113
114
115
116
117
# File 'lib/chocomint/edit_git_handlers.rb', line 106

def handle_edit_git_unstage(req, res)
  return edit_method_guard(res) unless req.request_method == "POST"

  with_git(res) do
    require_git_repo!
    paths = git_body_paths(req)
    out, err, status = unstage_paths(paths)
    raise Chocomint::Error, (err.empty? ? out : err) unless status.success?

    edit_json(res, 200, "unstaged" => paths)
  end
end

#handle_edit_git_unstage_all(req, res) ⇒ Object



131
132
133
134
135
136
137
138
139
140
141
# File 'lib/chocomint/edit_git_handlers.rb', line 131

def handle_edit_git_unstage_all(req, res)
  return edit_method_guard(res) unless req.request_method == "POST"

  with_git(res) do
    require_git_repo!
    out, err, status = unstage_paths(%w[.])
    raise Chocomint::Error, (err.empty? ? out : err) unless status.success?

    edit_json(res, 200, "unstaged_all" => true)
  end
end

#normalize_path(path) ⇒ Object



343
344
345
# File 'lib/chocomint/edit_git_handlers.rb', line 343

def normalize_path(path)
  File.expand_path(path).tr("\\", "/").downcase
end

#parse_git_status(lines) ⇒ Object

git status --porcelain=v1 の各行 (XY PATH) をステージ済み/未ステージに振り分ける。 X: インデックス(ステージ)の状態、Y: 作業ツリーの状態。 "??" (未追跡) は unstaged 扱いにする。



56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
# File 'lib/chocomint/edit_git_handlers.rb', line 56

def parse_git_status(lines)
  staged = []
  unstaged = []
  lines.each do |line|
    next if line.length < 4

    x = line[0]
    y = line[1]
    path = line[3..].to_s.tr("\\", "/")
    # rename は "R  old -> new" の形式。表示・操作対象は new 側のパス。
    path = path.split(" -> ").last if path.include?(" -> ")

    staged << { "path" => path, "status" => x } if x != " " && x != "?"
    unstaged << { "path" => path, "status" => y } if y != " " || x == "?"
  end
  [staged, unstaged]
end

#push_target_missing?(message) ⇒ Boolean

push 先が無いことを示すエラー文言か判定する (リモート未設定 / 上流ブランチ未設定の両方)。

Returns:

  • (Boolean)


234
235
236
237
# File 'lib/chocomint/edit_git_handlers.rb', line 234

def push_target_missing?(message)
  message.include?("No configured push destination") ||
    message.include?("has no upstream branch")
end

#require_git_repo!Object

edit_root がリポジトリのトップレベルでなければ拒否する。init/checkout 以外の 全操作の先頭で呼び、祖先ディレクトリの .git を誤って操作しないようにする。

Raises:



382
383
384
# File 'lib/chocomint/edit_git_handlers.rb', line 382

def require_git_repo!
  raise Chocomint::Error, "git 管理下ではありません" unless git_repo?
end

#same_path?(git_path, ruby_path) ⇒ Boolean

git の出力 (常に "/" 区切り) と Ruby 側の絶対パスを比較する。 Windows はドライブレターの大小文字揺れがあるため大文字小文字を無視する。

Returns:

  • (Boolean)


339
340
341
# File 'lib/chocomint/edit_git_handlers.rb', line 339

def same_path?(git_path, ruby_path)
  normalize_path(git_path) == normalize_path(ruby_path)
end

#unstage_paths(paths) ⇒ Object

ステージ済みの変更を取り消す。git restore --staged は初回コミット前 (HEAD が無い) だと "fatal: could not resolve HEAD" で失敗するため、 その場合は index をリセットする git reset にフォールバックする。



177
178
179
180
181
182
# File 'lib/chocomint/edit_git_handlers.rb', line 177

def unstage_paths(paths)
  out, err, status = git_run(["restore", "--staged", "--"] + paths)
  return [out, err, status] if status.success?

  git_run(["reset", "--"] + paths)
end

#with_git(res) ⇒ Object

Git 操作系ハンドラ共通の例外→HTTP 変換。



387
388
389
390
391
392
393
394
395
# File 'lib/chocomint/edit_git_handlers.rb', line 387

def with_git(res)
  yield
rescue JSON::ParserError => e
  edit_json(res, 400, "error" => "invalid JSON: #{e.message}")
rescue Chocomint::Error => e
  edit_json(res, 400, "error" => e.message)
rescue SystemCallError => e
  edit_json(res, 500, "error" => e.message)
end