ruvim 0.3.0 → 0.4.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +18 -6
  3. data/README.md +15 -1
  4. data/docs/binding.md +16 -0
  5. data/docs/command.md +78 -4
  6. data/docs/config.md +10 -2
  7. data/docs/spec.md +60 -9
  8. data/docs/tutorial.md +24 -0
  9. data/docs/vim_diff.md +18 -8
  10. data/lib/ruvim/app.rb +290 -8
  11. data/lib/ruvim/buffer.rb +14 -2
  12. data/lib/ruvim/cli.rb +6 -0
  13. data/lib/ruvim/editor.rb +12 -1
  14. data/lib/ruvim/file_watcher.rb +243 -0
  15. data/lib/ruvim/git/blame.rb +245 -0
  16. data/lib/ruvim/git/branch.rb +97 -0
  17. data/lib/ruvim/git/commit.rb +102 -0
  18. data/lib/ruvim/git/diff.rb +129 -0
  19. data/lib/ruvim/git/handler.rb +84 -0
  20. data/lib/ruvim/git/log.rb +41 -0
  21. data/lib/ruvim/git/status.rb +103 -0
  22. data/lib/ruvim/global_commands.rb +176 -42
  23. data/lib/ruvim/highlighter.rb +3 -1
  24. data/lib/ruvim/input.rb +1 -0
  25. data/lib/ruvim/lang/diff.rb +41 -0
  26. data/lib/ruvim/lang/json.rb +34 -0
  27. data/lib/ruvim/rich_view/json_renderer.rb +131 -0
  28. data/lib/ruvim/rich_view/jsonl_renderer.rb +57 -0
  29. data/lib/ruvim/rich_view.rb +16 -0
  30. data/lib/ruvim/screen.rb +9 -12
  31. data/lib/ruvim/version.rb +1 -1
  32. data/lib/ruvim.rb +10 -0
  33. data/test/app_completion_test.rb +25 -0
  34. data/test/app_scenario_test.rb +169 -0
  35. data/test/cli_test.rb +14 -0
  36. data/test/clipboard_test.rb +67 -0
  37. data/test/command_line_test.rb +118 -0
  38. data/test/config_dsl_test.rb +87 -0
  39. data/test/display_width_test.rb +41 -0
  40. data/test/file_watcher_test.rb +197 -0
  41. data/test/follow_test.rb +199 -0
  42. data/test/git_blame_test.rb +713 -0
  43. data/test/highlighter_test.rb +44 -0
  44. data/test/indent_test.rb +86 -0
  45. data/test/rich_view_test.rb +256 -0
  46. data/test/search_option_test.rb +19 -0
  47. data/test/test_helper.rb +9 -0
  48. metadata +17 -1
@@ -0,0 +1,713 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+ require "fileutils"
5
+ require "tmpdir"
6
+
7
+ class GitBlameTest < Minitest::Test
8
+ def setup
9
+ @app = RuVim::App.new(clean: true)
10
+ @editor = @app.instance_variable_get(:@editor)
11
+ @dispatcher = @app.instance_variable_get(:@dispatcher)
12
+ @editor.materialize_intro_buffer!
13
+ end
14
+
15
+ def feed(*keys)
16
+ keys.each { |k| @app.send(:handle_key, k) }
17
+ end
18
+
19
+ def drain_git_stream!
20
+ threads = @app.instance_variable_get(:@git_stream_threads)
21
+ threads&.each_value(&:join)
22
+ @app.send(:drain_stream_events!)
23
+ end
24
+
25
+ # --- Parsing ---
26
+
27
+ def test_parse_porcelain_basic
28
+ porcelain = <<~PORCELAIN
29
+ abc1234abc1234abc1234abc1234abc1234abc123 1 1 1
30
+ author Alice
31
+ author-mail <alice@example.com>
32
+ author-time 1700000000
33
+ author-tz +0900
34
+ committer Alice
35
+ committer-mail <alice@example.com>
36
+ committer-time 1700000000
37
+ committer-tz +0900
38
+ summary Initial commit
39
+ filename foo.rb
40
+ \thello world
41
+ PORCELAIN
42
+
43
+ entries = RuVim::Git::Blame.parse_porcelain(porcelain)
44
+ assert_equal 1, entries.length
45
+ e = entries.first
46
+ assert_equal "abc1234a", e[:short_hash]
47
+ assert_equal "Alice", e[:author]
48
+ assert_equal "Initial commit", e[:summary]
49
+ assert_equal "hello world", e[:text]
50
+ assert_equal 1, e[:orig_line]
51
+ end
52
+
53
+ def test_parse_porcelain_multi_line
54
+ porcelain = <<~PORCELAIN
55
+ aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111 1 1 2
56
+ author Alice
57
+ author-mail <alice@example.com>
58
+ author-time 1700000000
59
+ author-tz +0900
60
+ committer Alice
61
+ committer-mail <alice@example.com>
62
+ committer-time 1700000000
63
+ committer-tz +0900
64
+ summary First
65
+ filename foo.rb
66
+ \tline one
67
+ aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111 2 2
68
+ \tline two
69
+ PORCELAIN
70
+
71
+ entries = RuVim::Git::Blame.parse_porcelain(porcelain)
72
+ assert_equal 2, entries.length
73
+ assert_equal "line one", entries[0][:text]
74
+ assert_equal "line two", entries[1][:text]
75
+ assert_equal "aaaa1111", entries[0][:short_hash]
76
+ assert_equal "aaaa1111", entries[1][:short_hash]
77
+ end
78
+
79
+ def test_format_blame_lines
80
+ entries = [
81
+ { short_hash: "abc12345", author: "Alice", date: "2023-11-14", text: "hello", orig_line: 1, hash: "abc12345" * 5 },
82
+ { short_hash: "def67890", author: "Bob", date: "2023-11-15", text: "world", orig_line: 2, hash: "def67890" * 5 },
83
+ ]
84
+ lines = RuVim::Git::Blame.format_lines(entries)
85
+ assert_equal 2, lines.length
86
+ assert_includes lines[0], "abc12345"
87
+ assert_includes lines[0], "Alice"
88
+ assert_includes lines[0], "hello"
89
+ assert_includes lines[1], "Bob"
90
+ end
91
+
92
+ # --- Status filename parsing ---
93
+
94
+ def test_parse_status_modified_line
95
+ assert_equal "lib/ruvim/app.rb", RuVim::Git::Status.parse_filename("\tmodified: lib/ruvim/app.rb")
96
+ end
97
+
98
+ def test_parse_status_new_file_line
99
+ assert_equal "foo.rb", RuVim::Git::Status.parse_filename("\tnew file: foo.rb")
100
+ end
101
+
102
+ def test_parse_status_untracked_line
103
+ assert_equal "bar.txt", RuVim::Git::Status.parse_filename("\tbar.txt")
104
+ end
105
+
106
+ def test_parse_status_header_returns_nil
107
+ assert_nil RuVim::Git::Status.parse_filename("Changes not staged for commit:")
108
+ assert_nil RuVim::Git::Status.parse_filename(" (use \"git add <file>...\")")
109
+ assert_nil RuVim::Git::Status.parse_filename("On branch master")
110
+ assert_nil RuVim::Git::Status.parse_filename("")
111
+ end
112
+
113
+ # --- Integration with App (using git repo) ---
114
+
115
+ def test_ctrl_g_enters_git_command_mode
116
+ feed(:ctrl_g)
117
+ assert_equal :command_line, @editor.mode
118
+ assert_equal ":git ", @editor.command_line.content
119
+ end
120
+
121
+ def test_git_blame_via_ex_git_subcommand
122
+ Dir.mktmpdir do |dir|
123
+ setup_git_repo(dir, "test_file.txt", "line1\nline2\n")
124
+
125
+ file_path = File.join(dir, "test_file.txt")
126
+ buf = @editor.add_buffer_from_file(file_path)
127
+ @editor.switch_to_buffer(buf.id)
128
+
129
+ @dispatcher.dispatch_ex(@editor, "git blame")
130
+
131
+ blame_buf = @editor.current_buffer
132
+ assert_equal :blame, blame_buf.kind
133
+ assert_equal 2, blame_buf.line_count
134
+ end
135
+ end
136
+
137
+ def test_git_blame_opens_blame_buffer
138
+ Dir.mktmpdir do |dir|
139
+ setup_git_repo(dir, "test_file.txt", "line1\nline2\nline3\n")
140
+
141
+ file_path = File.join(dir, "test_file.txt")
142
+ buf = @editor.add_buffer_from_file(file_path)
143
+ @editor.switch_to_buffer(buf.id)
144
+
145
+ @dispatcher.dispatch_ex(@editor, "git blame")
146
+
147
+ blame_buf = @editor.current_buffer
148
+ assert_equal :blame, blame_buf.kind
149
+ assert blame_buf.readonly?
150
+ refute blame_buf.modifiable?
151
+ assert_match(/\[Blame\]/, blame_buf.name)
152
+ assert_equal 3, blame_buf.line_count
153
+ end
154
+ end
155
+
156
+ def test_git_blame_buffer_local_bindings
157
+ Dir.mktmpdir do |dir|
158
+ setup_git_repo(dir, "test_file.txt", "line1\nline2\n")
159
+
160
+ file_path = File.join(dir, "test_file.txt")
161
+ buf = @editor.add_buffer_from_file(file_path)
162
+ @editor.switch_to_buffer(buf.id)
163
+
164
+ @dispatcher.dispatch_ex(@editor, "git blame")
165
+
166
+ blame_buf = @editor.current_buffer
167
+ assert_equal :blame, blame_buf.kind
168
+
169
+ # 'c' should be bound to git.blame.commit for this buffer
170
+ km = @app.instance_variable_get(:@keymaps)
171
+ match = km.resolve_with_context(:normal, ["c"], editor: @editor)
172
+ assert_equal :match, match.status
173
+ assert_equal "git.blame.commit", match.invocation.id
174
+ end
175
+ end
176
+
177
+ def test_git_blame_commit_opens_show_buffer
178
+ Dir.mktmpdir do |dir|
179
+ setup_git_repo(dir, "test_file.txt", "line1\nline2\n")
180
+
181
+ file_path = File.join(dir, "test_file.txt")
182
+ buf = @editor.add_buffer_from_file(file_path)
183
+ @editor.switch_to_buffer(buf.id)
184
+
185
+ @dispatcher.dispatch_ex(@editor, "git blame")
186
+ assert_equal :blame, @editor.current_buffer.kind
187
+
188
+ # Press 'c' to show commit
189
+ feed("c")
190
+
191
+ show_buf = @editor.current_buffer
192
+ assert_equal :git_show, show_buf.kind
193
+ refute show_buf.modifiable?
194
+ assert_match(/\[Commit\]/, show_buf.name)
195
+ end
196
+ end
197
+
198
+ def test_git_blame_prev_and_back
199
+ Dir.mktmpdir do |dir|
200
+ setup_git_repo_multi_commits(dir, "test_file.txt")
201
+
202
+ file_path = File.join(dir, "test_file.txt")
203
+ buf = @editor.add_buffer_from_file(file_path)
204
+ @editor.switch_to_buffer(buf.id)
205
+
206
+ @dispatcher.dispatch_ex(@editor, "git blame")
207
+ blame_buf = @editor.current_buffer
208
+ assert_equal :blame, blame_buf.kind
209
+
210
+ original_lines = blame_buf.lines.dup
211
+
212
+ # Press 'p' to go to previous blame
213
+ feed("p")
214
+ assert_equal :blame, @editor.current_buffer.kind
215
+ refute_equal original_lines, @editor.current_buffer.lines
216
+
217
+ # Press 'P' to go back
218
+ feed("P")
219
+ assert_equal :blame, @editor.current_buffer.kind
220
+ assert_equal original_lines, @editor.current_buffer.lines
221
+ end
222
+ end
223
+
224
+ def test_git_unknown_subcommand_shows_error
225
+ @dispatcher.dispatch_ex(@editor, "git unknown")
226
+ assert_match(/Unknown Git subcommand/, @editor.message)
227
+ end
228
+
229
+ def test_git_no_subcommand_shows_list
230
+ @dispatcher.dispatch_ex(@editor, "git")
231
+ assert_match(/blame/, @editor.message)
232
+ end
233
+
234
+ # --- GitStatus ---
235
+
236
+ def test_git_status
237
+ Dir.mktmpdir do |dir|
238
+ setup_git_repo(dir, "test_file.txt", "line1\n")
239
+
240
+ file_path = File.join(dir, "test_file.txt")
241
+ buf = @editor.add_buffer_from_file(file_path)
242
+ @editor.switch_to_buffer(buf.id)
243
+
244
+ @dispatcher.dispatch_ex(@editor, "git status")
245
+
246
+ status_buf = @editor.current_buffer
247
+ assert_equal :git_status, status_buf.kind
248
+ assert status_buf.readonly?
249
+ assert_match(/\[Git Status\]/, status_buf.name)
250
+ end
251
+ end
252
+
253
+ def test_git_status_enter_opens_file
254
+ Dir.mktmpdir do |dir|
255
+ setup_git_repo(dir, "test_file.txt", "line1\n")
256
+ # Create an uncommitted change so it shows in status
257
+ File.write(File.join(dir, "test_file.txt"), "modified\n")
258
+
259
+ file_path = File.join(dir, "test_file.txt")
260
+ buf = @editor.add_buffer_from_file(file_path)
261
+ @editor.switch_to_buffer(buf.id)
262
+
263
+ @dispatcher.dispatch_ex(@editor, "git status")
264
+ assert_equal :git_status, @editor.current_buffer.kind
265
+
266
+ # Move to a line containing the modified file
267
+ status_buf = @editor.current_buffer
268
+ target_line = status_buf.lines.index { |l| l.include?("test_file.txt") }
269
+ assert target_line, "Expected test_file.txt in status output"
270
+ @editor.current_window.cursor_y = target_line
271
+
272
+ feed(:enter)
273
+
274
+ # Should have opened test_file.txt
275
+ assert_equal File.join(dir, "test_file.txt"), @editor.current_buffer.path
276
+ end
277
+ end
278
+
279
+ # --- GitDiff ---
280
+
281
+ def test_git_diff
282
+ Dir.mktmpdir do |dir|
283
+ setup_git_repo(dir, "test_file.txt", "line1\n")
284
+ # Make an uncommitted change
285
+ File.write(File.join(dir, "test_file.txt"), "modified\n")
286
+
287
+ file_path = File.join(dir, "test_file.txt")
288
+ buf = @editor.add_buffer_from_file(file_path)
289
+ @editor.switch_to_buffer(buf.id)
290
+
291
+ @dispatcher.dispatch_ex(@editor, "git diff")
292
+
293
+ diff_buf = @editor.current_buffer
294
+ assert_equal :git_diff, diff_buf.kind
295
+ assert diff_buf.readonly?
296
+ assert_match(/\[Git Diff\]/, diff_buf.name)
297
+ end
298
+ end
299
+
300
+ def test_git_diff_parse_location_on_hunk_header
301
+ lines = [
302
+ "diff --git a/lib/foo.rb b/lib/foo.rb",
303
+ "--- a/lib/foo.rb",
304
+ "+++ b/lib/foo.rb",
305
+ "@@ -1,3 +1,4 @@",
306
+ " line1",
307
+ "+added",
308
+ " line2",
309
+ " line3",
310
+ ]
311
+ # On hunk header line → file at start of hunk
312
+ file, line = RuVim::Git::Diff.parse_location(lines, 3)
313
+ assert_equal "lib/foo.rb", file
314
+ assert_equal 1, line
315
+ end
316
+
317
+ def test_git_diff_parse_location_on_context_line
318
+ lines = [
319
+ "diff --git a/lib/foo.rb b/lib/foo.rb",
320
+ "--- a/lib/foo.rb",
321
+ "+++ b/lib/foo.rb",
322
+ "@@ -1,3 +1,4 @@",
323
+ " line1",
324
+ "+added",
325
+ " line2",
326
+ ]
327
+ # On " line2" (index 6): new-side lines are " line1"(+1), "+added"(+2), " line2"(+3)
328
+ file, line = RuVim::Git::Diff.parse_location(lines, 6)
329
+ assert_equal "lib/foo.rb", file
330
+ assert_equal 3, line
331
+ end
332
+
333
+ def test_git_diff_parse_location_on_added_line
334
+ lines = [
335
+ "diff --git a/lib/foo.rb b/lib/foo.rb",
336
+ "--- a/lib/foo.rb",
337
+ "+++ b/lib/foo.rb",
338
+ "@@ -1,3 +1,4 @@",
339
+ " line1",
340
+ "+added",
341
+ " line2",
342
+ ]
343
+ file, line = RuVim::Git::Diff.parse_location(lines, 5)
344
+ assert_equal "lib/foo.rb", file
345
+ assert_equal 2, line
346
+ end
347
+
348
+ def test_git_diff_parse_location_on_deleted_line
349
+ lines = [
350
+ "diff --git a/lib/foo.rb b/lib/foo.rb",
351
+ "--- a/lib/foo.rb",
352
+ "+++ b/lib/foo.rb",
353
+ "@@ -1,3 +1,3 @@",
354
+ " line1",
355
+ "-removed",
356
+ " line2",
357
+ ]
358
+ # Deleted line → jump to the next new-side line position
359
+ file, line = RuVim::Git::Diff.parse_location(lines, 5)
360
+ assert_equal "lib/foo.rb", file
361
+ assert_equal 2, line
362
+ end
363
+
364
+ def test_git_diff_parse_location_on_diff_header
365
+ lines = [
366
+ "diff --git a/lib/foo.rb b/lib/foo.rb",
367
+ "index abc..def 100644",
368
+ "--- a/lib/foo.rb",
369
+ "+++ b/lib/foo.rb",
370
+ "@@ -1,3 +1,3 @@",
371
+ ]
372
+ # On "diff --git" line → file, line 1
373
+ file, line = RuVim::Git::Diff.parse_location(lines, 0)
374
+ assert_equal "lib/foo.rb", file
375
+ assert_equal 1, line
376
+ end
377
+
378
+ def test_git_diff_parse_location_returns_nil_for_empty
379
+ assert_nil RuVim::Git::Diff.parse_location([], 0)
380
+ end
381
+
382
+ def test_git_diff_enter_opens_file
383
+ Dir.mktmpdir do |dir|
384
+ setup_git_repo(dir, "test_file.txt", "line1\nline2\nline3\n")
385
+ File.write(File.join(dir, "test_file.txt"), "line1\nmodified\nline3\n")
386
+
387
+ file_path = File.join(dir, "test_file.txt")
388
+ buf = @editor.add_buffer_from_file(file_path)
389
+ @editor.switch_to_buffer(buf.id)
390
+
391
+ @dispatcher.dispatch_ex(@editor, "git diff")
392
+ assert_equal :git_diff, @editor.current_buffer.kind
393
+
394
+ # Find a line with the modified content
395
+ diff_buf = @editor.current_buffer
396
+ target_line = diff_buf.lines.index { |l| l.start_with?("+modified") }
397
+ assert target_line, "Expected +modified in diff output"
398
+ @editor.current_window.cursor_y = target_line
399
+
400
+ feed(:enter)
401
+
402
+ # Should have opened test_file.txt
403
+ assert_equal File.join(dir, "test_file.txt"), @editor.current_buffer.path
404
+ end
405
+ end
406
+
407
+ def test_git_diff_clean_shows_message
408
+ Dir.mktmpdir do |dir|
409
+ setup_git_repo(dir, "test_file.txt", "line1\n")
410
+
411
+ file_path = File.join(dir, "test_file.txt")
412
+ buf = @editor.add_buffer_from_file(file_path)
413
+ @editor.switch_to_buffer(buf.id)
414
+
415
+ @dispatcher.dispatch_ex(@editor, "git diff")
416
+
417
+ # Should stay on original buffer, no diff buffer opened
418
+ assert_equal :file, @editor.current_buffer.kind
419
+ assert_match(/clean/, @editor.message)
420
+ end
421
+ end
422
+
423
+ # --- GitLog ---
424
+
425
+ def test_git_log
426
+ Dir.mktmpdir do |dir|
427
+ setup_git_repo(dir, "test_file.txt", "line1\n")
428
+
429
+ file_path = File.join(dir, "test_file.txt")
430
+ buf = @editor.add_buffer_from_file(file_path)
431
+ @editor.switch_to_buffer(buf.id)
432
+
433
+ @dispatcher.dispatch_ex(@editor, "git log")
434
+ drain_git_stream!
435
+
436
+ log_buf = @editor.current_buffer
437
+ assert_equal :git_log, log_buf.kind
438
+ assert log_buf.readonly?
439
+ assert_match(/\[Git Log\]/, log_buf.name)
440
+ assert log_buf.lines.any? { |l| l.include?("initial") }
441
+ end
442
+ end
443
+
444
+ def test_git_log_with_p_flag
445
+ Dir.mktmpdir do |dir|
446
+ setup_git_repo(dir, "test_file.txt", "line1\n")
447
+
448
+ file_path = File.join(dir, "test_file.txt")
449
+ buf = @editor.add_buffer_from_file(file_path)
450
+ @editor.switch_to_buffer(buf.id)
451
+
452
+ @dispatcher.dispatch_ex(@editor, "git log -p")
453
+ drain_git_stream!
454
+
455
+ log_buf = @editor.current_buffer
456
+ assert_equal :git_log, log_buf.kind
457
+ # -p shows diffs, so output should include diff markers
458
+ assert log_buf.lines.any? { |l| l.start_with?("diff") || l.start_with?("+++") || l.start_with?("---") }
459
+ end
460
+ end
461
+
462
+ # --- GitBranch ---
463
+
464
+ def test_git_branch
465
+ Dir.mktmpdir do |dir|
466
+ setup_git_repo(dir, "test_file.txt", "line1\n")
467
+
468
+ file_path = File.join(dir, "test_file.txt")
469
+ buf = @editor.add_buffer_from_file(file_path)
470
+ @editor.switch_to_buffer(buf.id)
471
+
472
+ @dispatcher.dispatch_ex(@editor, "git branch")
473
+
474
+ branch_buf = @editor.current_buffer
475
+ assert_equal :git_branch, branch_buf.kind
476
+ assert branch_buf.readonly?
477
+ assert_match(/\[Git Branch\]/, branch_buf.name)
478
+ assert branch_buf.lines.any? { |l| l.include?("master") || l.include?("main") }
479
+ end
480
+ end
481
+
482
+ def test_git_branch_parse_name
483
+ assert_equal "master", RuVim::Git::Branch.parse_branch_name("* master\t2025-03-06\tInitial")
484
+ assert_equal "feature", RuVim::Git::Branch.parse_branch_name(" feature\t2025-03-05\tAdd feature")
485
+ assert_nil RuVim::Git::Branch.parse_branch_name("")
486
+ end
487
+
488
+ def test_git_branch_enter_checks_out
489
+ Dir.mktmpdir do |dir|
490
+ setup_git_repo(dir, "test_file.txt", "line1\n")
491
+ # Create a second branch
492
+ Dir.chdir(dir) do
493
+ system("git branch test-branch", exception: true)
494
+ end
495
+
496
+ file_path = File.join(dir, "test_file.txt")
497
+ buf = @editor.add_buffer_from_file(file_path)
498
+ @editor.switch_to_buffer(buf.id)
499
+
500
+ @dispatcher.dispatch_ex(@editor, "git branch")
501
+ assert_equal :git_branch, @editor.current_buffer.kind
502
+
503
+ branch_buf = @editor.current_buffer
504
+ target_line = branch_buf.lines.index { |l| l.include?("test-branch") }
505
+ assert target_line, "Expected test-branch in branch output"
506
+ @editor.current_window.cursor_y = target_line
507
+
508
+ feed(:enter)
509
+
510
+ # Branch buffer should be closed, and we should have switched
511
+ refute_equal :git_branch, @editor.current_buffer.kind
512
+ # Verify checkout happened
513
+ Dir.chdir(dir) do
514
+ current = `git rev-parse --abbrev-ref HEAD`.strip
515
+ assert_equal "test-branch", current
516
+ end
517
+ end
518
+ end
519
+
520
+ # --- GitCommit ---
521
+
522
+ def test_git_commit_prepare
523
+ Dir.mktmpdir do |dir|
524
+ setup_git_repo(dir, "test_file.txt", "line1\n")
525
+ File.write(File.join(dir, "test_file.txt"), "modified\n")
526
+ Dir.chdir(dir) { system("git add test_file.txt", exception: true) }
527
+
528
+ lines, root, err = RuVim::Git::Commit.prepare(File.join(dir, "test_file.txt"))
529
+ assert_nil err
530
+ assert_equal dir, File.realpath(root)
531
+ assert_equal "", lines.first # Empty line for message
532
+ assert lines.any? { |l| l.start_with?("#") }
533
+ end
534
+ end
535
+
536
+ def test_git_commit_extract_message
537
+ lines = ["Fix the bug", "", "Detailed description", "# comment line", "# another"]
538
+ msg = RuVim::Git::Commit.extract_message(lines)
539
+ assert_equal "Fix the bug\n\nDetailed description", msg
540
+ end
541
+
542
+ def test_git_commit_extract_message_empty
543
+ lines = ["# comment only", "# another"]
544
+ msg = RuVim::Git::Commit.extract_message(lines)
545
+ assert_equal "", msg
546
+ end
547
+
548
+ def test_git_commit_opens_buffer
549
+ Dir.mktmpdir do |dir|
550
+ setup_git_repo(dir, "test_file.txt", "line1\n")
551
+
552
+ file_path = File.join(dir, "test_file.txt")
553
+ buf = @editor.add_buffer_from_file(file_path)
554
+ @editor.switch_to_buffer(buf.id)
555
+
556
+ @dispatcher.dispatch_ex(@editor, "git commit")
557
+
558
+ commit_buf = @editor.current_buffer
559
+ assert_equal :git_commit, commit_buf.kind
560
+ refute commit_buf.readonly?
561
+ assert commit_buf.modifiable?
562
+ assert_equal :insert, @editor.mode
563
+ end
564
+ end
565
+
566
+ def test_git_commit_via_write
567
+ Dir.mktmpdir do |dir|
568
+ setup_git_repo(dir, "test_file.txt", "line1\n")
569
+ File.write(File.join(dir, "test_file.txt"), "modified\n")
570
+ Dir.chdir(dir) { system("git add test_file.txt", exception: true) }
571
+
572
+ file_path = File.join(dir, "test_file.txt")
573
+ buf = @editor.add_buffer_from_file(file_path)
574
+ @editor.switch_to_buffer(buf.id)
575
+
576
+ @dispatcher.dispatch_ex(@editor, "git commit")
577
+ assert_equal :git_commit, @editor.current_buffer.kind
578
+
579
+ # Type a commit message on the first line
580
+ commit_buf = @editor.current_buffer
581
+ commit_buf.lines[0] = "Test commit message"
582
+
583
+ # :w should trigger the commit
584
+ @dispatcher.dispatch_ex(@editor, "w")
585
+
586
+ # Commit buffer should be closed
587
+ refute_equal :git_commit, @editor.current_buffer.kind
588
+
589
+ # Verify commit happened
590
+ Dir.chdir(dir) do
591
+ log = `git log --oneline -1`.strip
592
+ assert_includes log, "Test commit message"
593
+ end
594
+ end
595
+ end
596
+
597
+ def test_git_commit_empty_message_aborts
598
+ Dir.mktmpdir do |dir|
599
+ setup_git_repo(dir, "test_file.txt", "line1\n")
600
+
601
+ file_path = File.join(dir, "test_file.txt")
602
+ buf = @editor.add_buffer_from_file(file_path)
603
+ @editor.switch_to_buffer(buf.id)
604
+
605
+ @dispatcher.dispatch_ex(@editor, "git commit")
606
+ assert_equal :git_commit, @editor.current_buffer.kind
607
+
608
+ # Don't type anything, just try to write
609
+ @dispatcher.dispatch_ex(@editor, "w")
610
+
611
+ # Should still be on commit buffer (abort didn't close it)
612
+ assert_equal :git_commit, @editor.current_buffer.kind
613
+ assert_match(/Empty commit message/, @editor.message)
614
+ end
615
+ end
616
+
617
+ # --- Close with Esc/C-c ---
618
+
619
+ def test_esc_closes_git_blame_buffer
620
+ Dir.mktmpdir do |dir|
621
+ setup_git_repo(dir, "test_file.txt", "line1\n")
622
+
623
+ file_path = File.join(dir, "test_file.txt")
624
+ buf = @editor.add_buffer_from_file(file_path)
625
+ original_buf_id = buf.id
626
+ @editor.switch_to_buffer(buf.id)
627
+
628
+ @dispatcher.dispatch_ex(@editor, "git blame")
629
+ assert_equal :blame, @editor.current_buffer.kind
630
+
631
+ feed(:escape)
632
+ assert_equal original_buf_id, @editor.current_buffer.id
633
+ end
634
+ end
635
+
636
+ def test_esc_closes_git_status_buffer
637
+ Dir.mktmpdir do |dir|
638
+ setup_git_repo(dir, "test_file.txt", "line1\n")
639
+
640
+ file_path = File.join(dir, "test_file.txt")
641
+ buf = @editor.add_buffer_from_file(file_path)
642
+ original_buf_id = buf.id
643
+ @editor.switch_to_buffer(buf.id)
644
+
645
+ @dispatcher.dispatch_ex(@editor, "git status")
646
+ assert_equal :git_status, @editor.current_buffer.kind
647
+
648
+ feed(:escape)
649
+ assert_equal original_buf_id, @editor.current_buffer.id
650
+ end
651
+ end
652
+
653
+ def test_esc_closes_git_log_buffer
654
+ Dir.mktmpdir do |dir|
655
+ setup_git_repo(dir, "test_file.txt", "line1\n")
656
+
657
+ file_path = File.join(dir, "test_file.txt")
658
+ buf = @editor.add_buffer_from_file(file_path)
659
+ original_buf_id = buf.id
660
+ @editor.switch_to_buffer(buf.id)
661
+
662
+ @dispatcher.dispatch_ex(@editor, "git log")
663
+ assert_equal :git_log, @editor.current_buffer.kind
664
+
665
+ feed(:escape)
666
+ assert_equal original_buf_id, @editor.current_buffer.id
667
+ end
668
+ end
669
+
670
+ # --- Error cases ---
671
+
672
+ def test_git_blame_on_non_git_file_shows_error
673
+ Dir.mktmpdir do |dir|
674
+ file_path = File.join(dir, "no_git.txt")
675
+ File.write(file_path, "hello\n")
676
+
677
+ buf = @editor.add_buffer_from_file(file_path)
678
+ @editor.switch_to_buffer(buf.id)
679
+
680
+ @dispatcher.dispatch_ex(@editor, "git blame")
681
+
682
+ # Should remain on original buffer (blame failed)
683
+ assert_equal :file, @editor.current_buffer.kind
684
+ end
685
+ end
686
+
687
+ private
688
+
689
+ def setup_git_repo(dir, filename, content)
690
+ Dir.chdir(dir) do
691
+ system("git init -q .", exception: true)
692
+ system("git config user.email test@test.com", exception: true)
693
+ system("git config user.name Test", exception: true)
694
+ File.write(filename, content)
695
+ system("git add #{filename}", exception: true)
696
+ system("git commit -q -m 'initial' --no-gpg-sign", exception: true)
697
+ end
698
+ end
699
+
700
+ def setup_git_repo_multi_commits(dir, filename)
701
+ Dir.chdir(dir) do
702
+ system("git init -q .", exception: true)
703
+ system("git config user.email test@test.com", exception: true)
704
+ system("git config user.name Test", exception: true)
705
+ File.write(filename, "original line\n")
706
+ system("git add #{filename}", exception: true)
707
+ system("git commit -q -m 'first commit' --no-gpg-sign", exception: true)
708
+ File.write(filename, "modified line\n")
709
+ system("git add #{filename}", exception: true)
710
+ system("git commit -q -m 'second commit' --no-gpg-sign", exception: true)
711
+ end
712
+ end
713
+ end