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