reflex-terminal 0.1.0 → 0.2.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.
@@ -31,10 +31,10 @@ class TestRenderer < Test::Unit::TestCase
31
31
  r = renderer f
32
32
  # the font crosses back as a copy, so it is the same one by its own
33
33
  # measure rather than by object
34
- assert_equal f.name, r.font.name
35
- assert_equal f.size, r.font.size
36
- assert_equal f.width('M'), r.cell_width
37
- assert_equal f.height.ceil, r.cell_height
34
+ assert_equal f.name, r.font.name
35
+ assert_equal f.size, r.font.size
36
+ assert_equal f.width('M').ceil, r.cell_width
37
+ assert_equal f.height.ceil, r.cell_height
38
38
 
39
39
  # the atlas holds glyphs of one size, so it goes with the old font
40
40
  r.bake_glyphs terminal('hello')
@@ -66,6 +66,124 @@ class TestRenderer < Test::Unit::TestCase
66
66
  assert_equal ascii + 3, r.glyph_count
67
67
  end
68
68
 
69
+ def test_bake_glyphs_by_style()
70
+ r = renderer
71
+ ascii = (0x20..0x7e).size
72
+
73
+ # a styled cell is another glyph even for a character the seed holds:
74
+ # each face keeps glyphs of its own, over the one shared atlas
75
+ r.bake_glyphs terminal("a \e[1ma\e[0m \e[3ma\e[0m \e[1;3ma\e[0m")
76
+ assert_equal ascii + 3, r.glyph_count
77
+
78
+ # and only the regular face is seeded with ascii
79
+ r.bake_glyphs terminal("\e[1mb")
80
+ assert_equal ascii + 4, r.glyph_count
81
+ end
82
+
83
+ def test_draw_styles()
84
+ r = renderer
85
+ t = terminal "aaaa \e[1maaaa\e[0m \e[3maaaa\e[0m"
86
+ r.bake_glyphs t
87
+
88
+ # the same character in another style cannot draw the same
89
+ plain = pixels(r, terminal('aaaa'))
90
+ bold = pixels(r, terminal("\e[1maaaa"))
91
+ italic = pixels(r, terminal("\e[3maaaa"))
92
+ assert_not_equal plain, bold
93
+ assert_not_equal plain, italic
94
+ assert_not_equal bold, italic
95
+ end
96
+
97
+ def test_draw_decorations()
98
+ r = renderer
99
+ styles = {
100
+ single: "\e[4m",
101
+ double: "\e[21m",
102
+ curly: "\e[4:3m",
103
+ dotted: "\e[4:4m",
104
+ dashed: "\e[4:5m",
105
+ strikethrough: "\e[9m",
106
+ overline: "\e[53m",
107
+ }
108
+ r.bake_glyphs terminal('aaaa')
109
+
110
+ # every decoration adds its own marks, and no two draw the same
111
+ plain = pixels(r, terminal('aaaa'))
112
+ drawn = styles.map {|name, sgr| [name, pixels(r, terminal("#{sgr}aaaa"))]}
113
+ drawn.each {|name, px| assert_not_equal plain, px, name}
114
+ drawn.combination(2).each do |(n1, p1), (n2, p2)|
115
+ assert_not_equal p1, p2, "#{n1} vs #{n2}"
116
+ end
117
+ end
118
+
119
+ def test_draw_decoration_colors()
120
+ r = renderer
121
+ r.bake_glyphs terminal('aaaa')
122
+
123
+ # SGR 58 colors the line apart from the text, and SGR 59 hands it back
124
+ default = pixels(r, terminal("\e[4maaaa"))
125
+ red = pixels(r, terminal("\e[4m\e[58:2::255:0:0maaaa"))
126
+ reset = pixels(r, terminal("\e[4m\e[58:2::255:0:0m\e[59maaaa"))
127
+ assert_not_equal default, red
128
+ assert_equal default, reset
129
+ end
130
+
131
+ def test_background_alpha()
132
+ r = renderer
133
+ assert_equal 1, r.background_alpha
134
+ assert_raise(ArgumentError) {r.background_alpha = 1.5}
135
+
136
+ # the theme background thins out, while a cell the child colored
137
+ # itself stays opaque over it
138
+ r.background_alpha = 0.5
139
+ assert_in_delta 0.5, pixels(r, terminal('')).first.last, 1.0 / 255
140
+
141
+ r.background_alpha = 0
142
+ assert_equal [0, 0, 0, 0], pixels(r, terminal('')).first
143
+ assert_equal 1, pixels(r, terminal("\e[41m ")).first.last
144
+
145
+ # a cell colored like the theme was still colored by the child, so it
146
+ # stays opaque rather than joining the background it happens to match
147
+ assert_equal 1, pixels(r, terminal("\e[48;2;0;0;0m ")).first.last
148
+ end
149
+
150
+ def test_draw_blink()
151
+ r = renderer
152
+ r.bake_glyphs terminal("\e[5;4mhello")
153
+ lit = pixels(r, terminal("\e[5;4mhello"))
154
+ plain = pixels(r, terminal('hello'))
155
+
156
+ # the off phase hides the glyphs and their decorations, but not the
157
+ # background, and leaves text without the attribute alone
158
+ r.blink_visible = false
159
+ assert_equal pixels(r, terminal('')), pixels(r, terminal("\e[5;4mhello"))
160
+ assert_not_equal pixels(r, terminal('')), pixels(r, terminal("\e[5;41mhello"))
161
+ assert_equal plain, pixels(r, terminal('hello'))
162
+
163
+ r.blink_visible = true
164
+ assert_equal lit, pixels(r, terminal("\e[5;4mhello"))
165
+ end
166
+
167
+ def test_draw_invisible()
168
+ r = renderer
169
+ r.bake_glyphs terminal('hello')
170
+
171
+ # conceal hides the glyphs, not the cell or its decorations
172
+ assert_equal pixels(r, terminal('')), pixels(r, terminal("\e[8mhello"))
173
+ assert_not_equal pixels(r, terminal('hello')), pixels(r, terminal("\e[8mhello"))
174
+ assert_not_equal pixels(r, terminal('')), pixels(r, terminal("\e[8;4mhello"))
175
+ assert_not_equal pixels(r, terminal('')), pixels(r, terminal("\e[8;41mhello"))
176
+ end
177
+
178
+ def test_bake_glyphs_skips_concealed()
179
+ r = renderer
180
+ ascii = (0x20..0x7e).size
181
+
182
+ # a concealed span draws no glyphs, so it bakes none either
183
+ r.bake_glyphs terminal("\e[8mあ")
184
+ assert_equal ascii, r.glyph_count
185
+ end
186
+
69
187
  def test_draw()
70
188
  r = renderer
71
189
  t = terminal "hello \e[31mworld\e[0m"
@@ -36,6 +36,22 @@ class TestTerminal < Test::Unit::TestCase
36
36
  R::KeyEvent.new R::KeyEvent::UP, chars, code, modifiers, 0
37
37
  end
38
38
 
39
+ # A spawned child is forked from this process, so its parent id and the
40
+ # script it was handed are enough to pick it out from everything running.
41
+ def child_pids(script)
42
+ `ps -eo pid,ppid,command`.lines.drop(1).filter_map do |line|
43
+ pid, ppid, command = line.strip.split ' ', 3
44
+ pid.to_i if ppid.to_i == Process.pid && command.include?(script)
45
+ end
46
+ end
47
+
48
+ def process_alive?(pid)
49
+ Process.kill 0, pid
50
+ true
51
+ rescue Errno::ESRCH
52
+ false
53
+ end
54
+
39
55
  def wait_for(t, timeout = 5)
40
56
  deadline = Time.now + timeout
41
57
  until yield
@@ -66,10 +82,10 @@ class TestTerminal < Test::Unit::TestCase
66
82
  spans = t.each_span.to_a
67
83
  assert_equal 2, spans.size
68
84
 
69
- x, y, width, text, fg, bg, flags = spans[0]
70
- assert_equal [0, 0, 3, %q[hi ], nil, nil, 0], [x, y, width, text, fg, bg, flags]
85
+ x, y, width, text, fg, bg, ul, flags = spans[0]
86
+ assert_equal [0, 0, 3, %q[hi ], nil, nil, nil, 0], [x, y, width, text, fg, bg, ul, flags]
71
87
 
72
- x, y, width, text, fg, bg, flags = spans[1]
88
+ x, y, width, text, fg, bg, ul, flags = spans[1]
73
89
  assert_equal [3, 0, 3, %q[red]], [x, y, width, text]
74
90
  assert_kind_of Integer, fg
75
91
  assert_nil bg
@@ -94,17 +110,56 @@ class TestTerminal < Test::Unit::TestCase
94
110
  assert_false t.update
95
111
  end
96
112
 
113
+ def test_update_sees_a_bare_cursor_motion()
114
+ t = terminal 80, 4
115
+ t.update
116
+
117
+ # moving the cursor dirties no cell, but whoever draws the screen
118
+ # draws the cursor too, so it has to count as damage
119
+ t.feed "\e[C"
120
+ assert_true t.update
121
+ assert_false t.update
122
+ end
123
+
97
124
  def test_each_span_attributes()
98
125
  t = terminal 80, 4
99
126
  t.feed "\e[1mB\e[0m \e[4mU\e[0m \e[7mR"
100
127
  t.update
101
128
 
102
- flags = t.each_span.map {|x, y, w, str, fg, bg, flags| flags}
129
+ flags = t.each_span.map {|x, y, w, str, fg, bg, ul, flags| flags}
103
130
  assert_equal T::BOLD, flags[0] & T::BOLD
104
131
  assert_equal 1, (flags[2] & T::UNDERLINE_MASK) >> T::UNDERLINE_SHIFT
105
132
  assert_equal T::INVERSE, flags[4] & T::INVERSE
106
133
  end
107
134
 
135
+ def test_each_span_underline_color()
136
+ t = terminal 80, 4
137
+ t.feed "\e]4;12;#123456\a"# pin palette 12, whose default is the theme's
138
+ t.feed "\e[4mA\e[58:2::255:0:0mB\e[58:5:12mC\e[59mD"
139
+ t.update
140
+
141
+ uls = t.each_span.map {|x, y, w, str, fg, bg, ul, flags| ul}
142
+ assert_equal 4, uls.size# one span per color
143
+ assert_nil uls[0]
144
+ assert_equal 0xff0000, uls[1]
145
+ assert_equal 0x123456, uls[2]
146
+ assert_nil uls[3]
147
+ end
148
+
149
+ def test_blinking()
150
+ t = terminal 20, 4
151
+ t.update
152
+ assert_false t.blinking?
153
+
154
+ t.feed "\e[5mB"
155
+ t.update
156
+ assert_true t.blinking?
157
+
158
+ t.feed "\e[H\e[2J"
159
+ t.update
160
+ assert_false t.blinking?
161
+ end
162
+
108
163
  def test_wide_chars()
109
164
  t = terminal 20, 4
110
165
  t.feed %q[あい]
@@ -281,6 +336,17 @@ class TestTerminal < Test::Unit::TestCase
281
336
  assert_equal 'A', t.read_pending_input
282
337
  end
283
338
 
339
+ def test_write_key_kitty_arrows()
340
+ t = terminal
341
+ # macos delivers an arrow with its function-key character (U+F702) as
342
+ # chars. legacy encoding takes the key over the text, but the kitty
343
+ # protocol sends the text when there is one, so the character has to
344
+ # be recognized as a key rather than text
345
+ t.feed "\e[>1u"
346
+ t.write_key key_down("\uF702", R::KEY_LEFT)
347
+ assert_equal "\e[D", t.read_pending_input
348
+ end
349
+
284
350
  def test_write_key_ctrl()
285
351
  t = terminal
286
352
  # ghostty leaves these to the kitty protocol (fixterms), so they would
@@ -322,6 +388,17 @@ class TestTerminal < Test::Unit::TestCase
322
388
  assert_equal "\e[13;1:3u", t.read_pending_input
323
389
  end
324
390
 
391
+ def test_write_key_release_of_committed_text()
392
+ t = terminal
393
+ # a key event synthesized for a committed text carries the text as chars
394
+ # and no key at all; its release must not write the text a second time
395
+ t.feed "\e[>3u"# report event types
396
+ t.write_key key_down('あ', -1)# KEY_NONE
397
+ assert_equal 'あ'.b, t.read_pending_input
398
+ t.write_key key_up('あ', -1)
399
+ assert_equal '', t.read_pending_input
400
+ end
401
+
325
402
  def test_read_pending_input()
326
403
  t = terminal
327
404
  t.write_key key_down("\r", R::KEY_ENTER)
@@ -388,6 +465,24 @@ class TestTerminal < Test::Unit::TestCase
388
465
  assert_include text(t), 'after close'
389
466
  end
390
467
 
468
+ def test_close_ends_the_child()
469
+ # a child that never reads its end of the terminal, so nothing short of
470
+ # a signal ends it. alive? says nothing about that: close() forgets the
471
+ # process, and a forgotten one is reported gone whether it is or not.
472
+ # The comment is a name, so that no other test's child answers to it
473
+ script = 'sleep# close-test'
474
+
475
+ t = terminal 40, 6
476
+ t.spawn(*ruby(script))
477
+ wait_for(t) {t.alive?}
478
+
479
+ pids = child_pids script
480
+ assert_equal 1, pids.size
481
+
482
+ t.close
483
+ assert_false process_alive?(pids.first)
484
+ end unless win32?# child_pids reads ps
485
+
391
486
  def test_spawn_with_args()
392
487
  t = terminal 40, 6
393
488
  t.spawn(*ruby('print "spawned"'))
@@ -459,16 +554,76 @@ class TestTerminal < Test::Unit::TestCase
459
554
  assert_equal "\e[<0;2;2M", t.read_pending_input# cell (2, 2), left press
460
555
  end
461
556
 
557
+ def test_write_pointer_cancel()
558
+ t = terminal 40, 10
559
+ t.resize 40, 10, cell_width: 8, cell_height: 16
560
+ t.feed "\e[?1000h\e[?1006h"# normal tracking + SGR format
561
+
562
+ mouse = R::Pointer::MOUSE
563
+ left = R::Pointer::MOUSE_LEFT
564
+ right = R::Pointer::MOUSE_RIGHT
565
+ press = -> types {R::PointerEvent.new(
566
+ R::Pointer.new(0, mouse | types, R::Pointer::DOWN, [12, 20], 0, 1, false, 0))}
567
+
568
+ t.read_pending_input
569
+ t.write_pointer press[left]
570
+ assert_equal "\e[<0;2;2M", t.read_pending_input
571
+ t.write_pointer press[right]
572
+ assert_equal "\e[<2;2;2M", t.read_pending_input
573
+
574
+ # a cancel carries every button being pressed, and the child hears each
575
+ # of them released
576
+ t.write_pointer R::PointerEvent.new(
577
+ R::Pointer.new(
578
+ 0, mouse | left | right, R::Pointer::CANCEL, [12, 20], 0, 1, false, 0))
579
+ assert_equal "\e[<0;2;2m\e[<2;2;2m", t.read_pending_input
580
+ end
581
+
582
+ def test_mouse_encoding_with_a_fractional_cell()
583
+ # rounding the cell width would land this press cells off
584
+ t = terminal 100, 10
585
+ t.resize 100, 10, cell_width: 8.43, cell_height: 18.62
586
+ t.feed "\e[?1000h\e[?1006h"
587
+
588
+ types = R::Pointer::MOUSE | R::Pointer::MOUSE_LEFT
589
+ t.read_pending_input
590
+ t.write_pointer R::PointerEvent.new(
591
+ R::Pointer.new(0, types, R::Pointer::DOWN, [79 * 8.43 + 4, 5], 0, 1, false, 0))
592
+ assert_equal "\e[<0;80;1M", t.read_pending_input
593
+ end
594
+
595
+ def test_mouse_encoding_in_sgr_pixels_mode()
596
+ # the offset inside the cell survives, scaled onto the integral grid
597
+ t = terminal 100, 10
598
+ t.resize 100, 10, cell_width: 8.43, cell_height: 18.62
599
+ t.feed "\e[?1000h\e[?1016h"
600
+
601
+ types = R::Pointer::MOUSE | R::Pointer::MOUSE_LEFT
602
+ t.read_pending_input
603
+ t.write_pointer R::PointerEvent.new(
604
+ R::Pointer.new(0, types, R::Pointer::DOWN, [79 * 8.43 + 2, 5], 0, 1, false, 0))
605
+ assert_equal "\e[<0;634;5M", t.read_pending_input
606
+ end
607
+
462
608
  def test_write_wheel()
463
609
  t = terminal 40, 10
464
610
  t.resize 40, 10, cell_width: 8, cell_height: 16
465
611
  t.feed "\e[?1000h\e[?1006h"
466
612
 
467
- # reflex counts a wheel delta downwards, so button 4 is a negative one
468
- t.write_wheel R::WheelEvent.new(0, 0, 0, 0, -1, 0, 0)
613
+ # a wheel delta is in pixels and reflex counts it downwards, so a cell
614
+ # height upwards is one row, and one row up is button 4
615
+ t.write_wheel R::WheelEvent.new(0, 0, 0, 0, -16, 0, 0)
469
616
  assert_equal "\e[<64;1;1M\e[<64;1;1m", t.read_pending_input
470
617
 
471
- t.write_wheel R::WheelEvent.new(0, 0, 0, 0, 1, 0, 0)
618
+ t.write_wheel R::WheelEvent.new(0, 0, 0, 0, 16, 0, 0)
619
+ assert_equal "\e[<65;1;1M\e[<65;1;1m", t.read_pending_input
620
+
621
+ # what does not add up to a whole row waits for the next delta rather
622
+ # than being dropped
623
+ t.write_wheel R::WheelEvent.new(0, 0, 0, 0, 8, 0, 0)
624
+ assert_equal '', t.read_pending_input
625
+
626
+ t.write_wheel R::WheelEvent.new(0, 0, 0, 0, 8, 0, 0)
472
627
  assert_equal "\e[<65;1;1M\e[<65;1;1m", t.read_pending_input
473
628
  end
474
629
 
@@ -516,7 +671,7 @@ class TestTerminal < Test::Unit::TestCase
516
671
  assert_equal 'あ', t.selected_text
517
672
  assert_equal(
518
673
  [['あ', true], ['いうえお', false]],
519
- t.each_span.map {|x, y, w, str, fg, bg, flags|
674
+ t.each_span.map {|x, y, w, str, fg, bg, ul, flags|
520
675
  [str, (flags & T::SELECTED) != 0]})
521
676
  end
522
677
 
@@ -12,7 +12,7 @@ class TestTerminalView < Test::Unit::TestCase
12
12
  # drag that would reach back to one
13
13
  def press(view, x, y, click_count: 1, modifiers: 0, button: Reflex::Pointer::MOUSE_LEFT)
14
14
  position = Reflex::Point.new(
15
- x * view.font.width('M'), y * view.font.height.ceil)
15
+ x * view.font.width('M').ceil, y * view.font.height.ceil)
16
16
  Reflex::PointerEvent.new Reflex::Pointer.new(
17
17
  1, Reflex::Pointer::MOUSE | button,
18
18
  Reflex::Pointer::DOWN, position, modifiers, click_count, false, 0)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: reflex-terminal
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - xordog
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-04 00:00:00.000000000 Z
11
+ date: 2026-11-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: xot
@@ -16,56 +16,56 @@ dependencies:
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: 0.4.0
19
+ version: 0.5.0
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: 0.4.0
26
+ version: 0.5.0
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: rucy
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
31
  - - "~>"
32
32
  - !ruby/object:Gem::Version
33
- version: 0.4.0
33
+ version: 0.5.0
34
34
  type: :runtime
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
- version: 0.4.0
40
+ version: 0.5.0
41
41
  - !ruby/object:Gem::Dependency
42
42
  name: rays
43
43
  requirement: !ruby/object:Gem::Requirement
44
44
  requirements:
45
45
  - - "~>"
46
46
  - !ruby/object:Gem::Version
47
- version: 0.4.0
47
+ version: 0.5.0
48
48
  type: :runtime
49
49
  prerelease: false
50
50
  version_requirements: !ruby/object:Gem::Requirement
51
51
  requirements:
52
52
  - - "~>"
53
53
  - !ruby/object:Gem::Version
54
- version: 0.4.0
54
+ version: 0.5.0
55
55
  - !ruby/object:Gem::Dependency
56
56
  name: reflexion
57
57
  requirement: !ruby/object:Gem::Requirement
58
58
  requirements:
59
59
  - - "~>"
60
60
  - !ruby/object:Gem::Version
61
- version: 0.6.0
61
+ version: 0.7.0
62
62
  type: :runtime
63
63
  prerelease: false
64
64
  version_requirements: !ruby/object:Gem::Requirement
65
65
  requirements:
66
66
  - - "~>"
67
67
  - !ruby/object:Gem::Version
68
- version: 0.6.0
68
+ version: 0.7.0
69
69
  description: Provides Reflex::Terminal, a headless terminal emulator model built on
70
70
  libghostty-vt, and Reflex::TerminalView, a View that renders it in the Reflex GUI
71
71
  toolkit.
@@ -85,10 +85,10 @@ files:
85
85
  - ".github/workflows/release-gem.yml"
86
86
  - ".github/workflows/tag.yml"
87
87
  - ".github/workflows/test.yml"
88
- - ".github/workflows/utils.rb"
89
88
  - CONTRIBUTING.md
90
89
  - ChangeLog.md
91
90
  - Gemfile
91
+ - Gemfile.lock
92
92
  - LICENSE
93
93
  - README.md
94
94
  - Rakefile
@@ -141,7 +141,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
141
141
  - !ruby/object:Gem::Version
142
142
  version: '0'
143
143
  requirements: []
144
- rubygems_version: 3.4.19
144
+ rubygems_version: 3.5.22
145
145
  signing_key:
146
146
  specification_version: 4
147
147
  summary: A terminal emulator view for Reflex.
@@ -1,127 +0,0 @@
1
- require 'shellwords'
2
-
3
- ALL_REPO = 'xord/all'
4
- ALL_DIR = '../all'
5
- ALL_FETCH_DEPTH = 100
6
-
7
- RENAMES = {reflex: 'reflexion'}
8
-
9
- def sh(cmd)
10
- puts cmd
11
- system cmd
12
- end
13
-
14
- def setup_dependencies(only: nil)
15
- gemspec_path = `git ls-files`.lines(chomp: true).find {|l| l =~ /\.gemspec$/}
16
- return unless gemspec_path
17
-
18
- gemspec = File.read gemspec_path
19
- name = File.basename gemspec_path, '.gemspec'
20
-
21
- exts = File.readlines('Rakefile')
22
- .map {|l| l[%r|^\s*require\W+([\w\-\_]+)/extension\W+$|, 1]}
23
- .compact
24
- .reject {|ext| ext == name}
25
- exts = exts & [only].flatten.map(&:to_s) if only
26
- return if exts.empty?
27
-
28
- unless setup_dependencies_via_monorepo(exts)
29
- setup_dependencies_via_each_repo_by_version(gemspec, exts)
30
- end
31
-
32
- exts.each {|ext| sh %( cd ../#{ext} && rake ext )}
33
- end
34
-
35
- def setup_dependencies_via_monorepo(exts)
36
- return false unless checkout_monorepo
37
- exts.each {|ext| sh %( ln -snf all/#{ext} ../#{ext} )}
38
- true
39
- end
40
-
41
- def checkout_monorepo()
42
- uuid = `git log -1 --format=%B`[/^\[\[([0-9a-fA-F-]+)\]\]$/, 1]
43
- return false unless uuid
44
-
45
- commit = setup_monorepo uuid
46
- return false unless commit
47
-
48
- Dir.chdir(ALL_DIR) {sh %( git checkout -q #{commit} )}
49
- true
50
- end
51
-
52
- def setup_monorepo(uuid)
53
- unless File.directory? ALL_DIR
54
- url = "https://github.com/#{ALL_REPO}.git"
55
- sh %( git clone --no-tags --depth #{ALL_FETCH_DEPTH} #{url} #{ALL_DIR} )
56
- end
57
- loop do
58
- commit = find_monorepo_commit uuid
59
- return commit if commit
60
-
61
- deepened = Dir.chdir ALL_DIR do
62
- before = `git rev-list --count HEAD`.to_i
63
- sh %( git fetch --deepen #{ALL_FETCH_DEPTH} )
64
- `git rev-list --count HEAD`.to_i > before
65
- end
66
- return nil unless deepened
67
- end
68
- end
69
-
70
- def find_monorepo_commit(uuid)
71
- Dir.chdir ALL_DIR do
72
- out = `git log origin/HEAD -F --grep="[[#{uuid}]]" --format=%H -1`.strip
73
- out.empty? ? nil : out
74
- end
75
- end
76
-
77
- def setup_dependencies_via_each_repo_by_version(gemspec, exts)
78
- exts.each do |ext|
79
- gem = RENAMES[ext.to_sym].then {|s| s || ext}
80
- ver = gemspec[/add_dependency.*['"]#{gem}['"].*['"]\s*~>\s*([\d\.]+)\s*['"]/, 1]
81
- opts = '-c advice.detachedHead=false --depth 1'
82
- clone = "git clone #{opts} https://github.com/xord/#{ext}.git ../#{ext}"
83
-
84
- # 'rake subtree:push' pushes all subrepos, so cloning by new tag
85
- # often fails before tagging each new tag
86
- sh %( #{clone} --branch v#{ver} || #{clone} )
87
- end
88
- end
89
-
90
- def tag_versions()
91
- changes = changelogs
92
- tags = `git tag`.lines chomp: true
93
- vers = `git log --oneline ./VERSION`
94
- .lines(chomp: true)
95
- .map {|line| line.split.first[/^\w+$/]}
96
- .map {|sha| [`git cat-file -p #{sha}:./VERSION 2>/dev/null`[/[\d\.]+/], sha]}
97
- .select {|ver, sha| ver && sha}
98
- .reverse
99
- .to_h
100
-
101
- vers.to_a.reverse.each do |ver, sha|
102
- tag = "v#{ver}"
103
- break if tags.include?(tag)
104
- sh %( git tag -a -m \"#{changes[tag]&.gsub '"', '\\"'}\" #{tag} #{sha} )
105
- end
106
- end
107
-
108
- def release(*paths)
109
- tag = ENV['GITHUB_REF']&.sub(%r|^refs/tags/|, '') || raise('GITHUB_REF tag not set')
110
- notes = (changelogs[tag] || '').shellescape
111
- paths = paths.flatten.join ' '
112
-
113
- sh(%( gh release create #{tag} #{paths} --notes #{notes} )) ||
114
- sh(%( gh release upload #{tag} #{paths} --clobber )) ||
115
- raise('failed to upload to releases')
116
- end
117
-
118
- def changelogs()
119
- File.read('ChangeLog.md')
120
- .split(/^\s*##\s*\[\s*(v[\d\.]+)\s*\].*$/)
121
- .slice(1..)
122
- .each_slice(2)
123
- .to_h
124
- .transform_values(&:strip!)
125
- rescue Errno::ENOENT
126
- raise 'failed to get changelogs'
127
- end