ratatui_ruby-devtools 0.2.1 → 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 (134) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +1 -1
  3. data/AGENTS.md +1 -1
  4. data/CHANGELOG.md +69 -7
  5. data/README.md +62 -21
  6. data/Rakefile +1 -1
  7. data/bin/setup +20 -6
  8. data/bin/setup.ps1 +74 -0
  9. data/doc/contributors/documentation_style.md +3 -4
  10. data/exe/scaffold +8 -342
  11. data/lib/ratatui_ruby/devtools/scaffold/scaffold_config.rb +67 -0
  12. data/lib/ratatui_ruby/devtools/scaffold/template_renderer.rb +282 -0
  13. data/lib/ratatui_ruby/devtools/task_scope.rb +24 -0
  14. data/lib/ratatui_ruby/devtools/tasks/autodoc_tasks.rb +31 -0
  15. data/lib/ratatui_ruby/devtools/tasks/bump/bump_workflow.rb +71 -0
  16. data/lib/ratatui_ruby/devtools/tasks/bump/changelog.rb +60 -3
  17. data/lib/ratatui_ruby/devtools/tasks/bump/github_project.rb +26 -0
  18. data/lib/ratatui_ruby/devtools/tasks/bump/history.rb +3 -3
  19. data/lib/ratatui_ruby/devtools/tasks/bump/links.rb +39 -24
  20. data/lib/ratatui_ruby/devtools/tasks/bump/manifest.rb +9 -17
  21. data/lib/ratatui_ruby/devtools/tasks/bump/no_crate.rb +19 -0
  22. data/lib/ratatui_ruby/devtools/tasks/bump/no_links.rb +22 -0
  23. data/lib/ratatui_ruby/devtools/tasks/bump/patch_release.rb +25 -0
  24. data/lib/ratatui_ruby/devtools/tasks/bump/release_branch.rb +17 -0
  25. data/lib/ratatui_ruby/devtools/tasks/bump/release_from_trunk.rb +48 -0
  26. data/lib/ratatui_ruby/devtools/tasks/bump/repository.rb +143 -0
  27. data/lib/ratatui_ruby/devtools/tasks/bump/ruby_gem.rb +65 -59
  28. data/lib/ratatui_ruby/devtools/tasks/bump/rust_crate.rb +42 -0
  29. data/lib/ratatui_ruby/devtools/tasks/bump/sem_ver.rb +127 -10
  30. data/lib/ratatui_ruby/devtools/tasks/bump/source_hut_project.rb +26 -0
  31. data/lib/ratatui_ruby/devtools/tasks/bump/unbranched_release.rb +20 -0
  32. data/lib/ratatui_ruby/devtools/tasks/bump/unreleased_section.rb +19 -1
  33. data/lib/ratatui_ruby/devtools/tasks/bump_tasks.rb +47 -0
  34. data/lib/ratatui_ruby/devtools/tasks/cargo_tasks.rb +57 -0
  35. data/lib/ratatui_ruby/devtools/tasks/doc/documentation.rb +59 -0
  36. data/lib/ratatui_ruby/devtools/tasks/doc/link/file_url.rb +30 -0
  37. data/lib/ratatui_ruby/devtools/tasks/doc/link/relative_path.rb +61 -0
  38. data/lib/ratatui_ruby/devtools/tasks/doc/link/web_url.rb +55 -0
  39. data/lib/ratatui_ruby/devtools/tasks/doc/link.rb +52 -0
  40. data/lib/ratatui_ruby/devtools/tasks/doc/link_audit.rb +116 -0
  41. data/lib/ratatui_ruby/devtools/tasks/doc/problem.rb +40 -0
  42. data/lib/ratatui_ruby/devtools/tasks/doc/source_file.rb +93 -0
  43. data/lib/ratatui_ruby/devtools/tasks/doc_tasks.rb +937 -0
  44. data/lib/ratatui_ruby/devtools/tasks/license/blame.rb +78 -0
  45. data/lib/ratatui_ruby/devtools/tasks/license/changed_files.rb +34 -0
  46. data/lib/ratatui_ruby/devtools/tasks/license/contribution.rb +12 -0
  47. data/lib/ratatui_ruby/devtools/tasks/license/copyright.rb +47 -0
  48. data/lib/ratatui_ruby/devtools/tasks/license/directory_licensing.rb +42 -0
  49. data/lib/ratatui_ruby/devtools/tasks/license/existing_header.rb +21 -0
  50. data/lib/ratatui_ruby/devtools/tasks/license/header_revision.rb +45 -0
  51. data/lib/ratatui_ruby/devtools/tasks/license/licensed_file.rb +39 -0
  52. data/lib/ratatui_ruby/devtools/tasks/license/licensing.rb +35 -0
  53. data/lib/ratatui_ruby/devtools/tasks/license/markdown_document.rb +23 -0
  54. data/lib/ratatui_ruby/devtools/tasks/license/markdown_files.rb +31 -0
  55. data/lib/ratatui_ruby/devtools/tasks/license/markdown_header_syntax.rb +104 -0
  56. data/lib/ratatui_ruby/devtools/tasks/license/markdown_snippet_syntax.rb +159 -0
  57. data/lib/ratatui_ruby/devtools/tasks/license/missing_header.rb +18 -0
  58. data/lib/ratatui_ruby/devtools/tasks/license/rbs_files.rb +27 -0
  59. data/lib/ratatui_ruby/devtools/tasks/license/rbs_header_syntax.rb +33 -0
  60. data/lib/ratatui_ruby/devtools/tasks/license/rdoc_comment.rb +51 -0
  61. data/lib/ratatui_ruby/devtools/tasks/license/rdoc_examples.rb +41 -0
  62. data/lib/ratatui_ruby/devtools/tasks/license/rdoc_snippet_syntax.rb +119 -0
  63. data/lib/ratatui_ruby/devtools/tasks/license/renewal.rb +17 -0
  64. data/lib/ratatui_ruby/devtools/tasks/license/reuse_annotated_file.rb +12 -0
  65. data/lib/ratatui_ruby/devtools/tasks/license/reuse_tool.rb +34 -0
  66. data/lib/ratatui_ruby/devtools/tasks/license/ruby_comment_header.rb +57 -0
  67. data/lib/ratatui_ruby/devtools/tasks/license/ruby_files.rb +36 -0
  68. data/lib/ratatui_ruby/devtools/tasks/license/ruby_header_syntax.rb +47 -0
  69. data/lib/ratatui_ruby/devtools/tasks/license/rust_files.rb +27 -0
  70. data/lib/ratatui_ruby/devtools/tasks/license/rust_header_syntax.rb +68 -0
  71. data/lib/ratatui_ruby/devtools/tasks/license/snippet.rb +44 -0
  72. data/lib/ratatui_ruby/devtools/tasks/license/snippet_file.rb +29 -0
  73. data/lib/ratatui_ruby/devtools/tasks/license/snippet_opening.rb +39 -0
  74. data/lib/ratatui_ruby/devtools/tasks/license/snippet_revision.rb +75 -0
  75. data/lib/ratatui_ruby/devtools/tasks/license/spdx_header.rb +42 -0
  76. data/lib/ratatui_ruby/devtools/tasks/license/unknown_file.rb +12 -0
  77. data/lib/ratatui_ruby/devtools/tasks/license/unlicensed_files.rb +69 -0
  78. data/lib/ratatui_ruby/devtools/tasks/license/wrapped_snippet.rb +30 -0
  79. data/lib/ratatui_ruby/devtools/tasks/license_tasks.rb +71 -0
  80. data/lib/ratatui_ruby/devtools/tasks/lint/gem_lockfile.rb +29 -0
  81. data/lib/ratatui_ruby/devtools/tasks/lint/supported_rubies.rb +18 -0
  82. data/lib/ratatui_ruby/devtools/tasks/lint_tasks.rb +100 -0
  83. data/lib/ratatui_ruby/devtools/tasks/release/platform_gem.rb +21 -12
  84. data/lib/ratatui_ruby/devtools/tasks/release/versioned_binary.rb +1 -1
  85. data/lib/ratatui_ruby/devtools/tasks/release_tasks.rb +80 -0
  86. data/lib/ratatui_ruby/devtools/tasks/reuse_tasks.rb +39 -0
  87. data/lib/ratatui_ruby/devtools/tasks/{test.rake → test_tasks.rb} +13 -4
  88. data/lib/ratatui_ruby/devtools/tasks/website/hosted_source_tree.rb +24 -0
  89. data/lib/ratatui_ruby/devtools/tasks/website/index_page.rb +1 -4
  90. data/lib/ratatui_ruby/devtools/tasks/website/source_tree.rb +63 -0
  91. data/lib/ratatui_ruby/devtools/tasks/website/versioned_documentation.rb +14 -13
  92. data/lib/ratatui_ruby/devtools/tasks/website_tasks.rb +49 -0
  93. data/lib/ratatui_ruby/devtools/templates/.github/workflows/build-gems.yml.erb +15 -9
  94. data/lib/ratatui_ruby/devtools/templates/.github/workflows/ci.yml.erb +2 -2
  95. data/lib/ratatui_ruby/devtools/templates/.gitignore.erb +2 -1
  96. data/lib/ratatui_ruby/devtools/templates/.rubocop.yml.erb +1 -1
  97. data/lib/ratatui_ruby/devtools/templates/AGENTS.md.erb +1 -1
  98. data/lib/ratatui_ruby/devtools/templates/CHANGELOG.md.erb +3 -1
  99. data/lib/ratatui_ruby/devtools/templates/CONTRIBUTING.md.erb +3 -3
  100. data/lib/ratatui_ruby/devtools/templates/Gemfile.erb +2 -3
  101. data/lib/ratatui_ruby/devtools/templates/README.md.erb +7 -7
  102. data/lib/ratatui_ruby/devtools/templates/REUSE.toml.erb +1 -1
  103. data/lib/ratatui_ruby/devtools/templates/bin/setup.erb +5 -1
  104. data/lib/ratatui_ruby/devtools/templates/bin/setup.ps1.erb +5 -1
  105. data/lib/ratatui_ruby/devtools/templates/doc/concepts/application_architecture.md.erb +2 -2
  106. data/lib/ratatui_ruby/devtools/templates/doc/concepts/application_testing.md.erb +3 -3
  107. data/lib/ratatui_ruby/devtools/templates/doc/getting_started/quickstart.md.erb +4 -4
  108. data/lib/ratatui_ruby/devtools/templates/ext/Cargo.toml.erb +1 -1
  109. data/lib/ratatui_ruby/devtools/templates/gemspec.erb +1 -1
  110. data/lib/ratatui_ruby/devtools/templates/mise.toml.erb +3 -3
  111. data/lib/ratatui_ruby/devtools/version.rb +1 -1
  112. data/lib/ratatui_ruby/devtools.rb +54 -71
  113. data/mise.toml +2 -2
  114. data/sig/ratatui_ruby/devtools.rbs +16 -1
  115. metadata +93 -34
  116. data/.builds/ruby-4.0.yml +0 -38
  117. data/lib/ratatui_ruby/devtools/tasks/autodoc.rake +0 -21
  118. data/lib/ratatui_ruby/devtools/tasks/bump.rake +0 -80
  119. data/lib/ratatui_ruby/devtools/tasks/cargo.rake +0 -47
  120. data/lib/ratatui_ruby/devtools/tasks/doc.rake +0 -887
  121. data/lib/ratatui_ruby/devtools/tasks/license/headers_md.rb +0 -276
  122. data/lib/ratatui_ruby/devtools/tasks/license/headers_rb.rb +0 -236
  123. data/lib/ratatui_ruby/devtools/tasks/license/license_utils.rb +0 -143
  124. data/lib/ratatui_ruby/devtools/tasks/license/snippets_md.rb +0 -353
  125. data/lib/ratatui_ruby/devtools/tasks/license/snippets_rdoc.rb +0 -186
  126. data/lib/ratatui_ruby/devtools/tasks/license.rake +0 -91
  127. data/lib/ratatui_ruby/devtools/tasks/lint.rake +0 -80
  128. data/lib/ratatui_ruby/devtools/tasks/release.rake +0 -87
  129. data/lib/ratatui_ruby/devtools/tasks/resources/rubies.yml +0 -7
  130. data/lib/ratatui_ruby/devtools/tasks/reuse.rake +0 -104
  131. data/lib/ratatui_ruby/devtools/tasks/website.rake +0 -37
  132. data/lib/ratatui_ruby/devtools/templates/.builds/ruby.yml.erb +0 -47
  133. data/lib/ratatui_ruby/devtools/templates/tasks/resources/index.html.erb +0 -45
  134. data/lib/ratatui_ruby/devtools/templates/tasks/resources/rubies.yml.erb +0 -8
@@ -5,6 +5,8 @@
5
5
  # SPDX-License-Identifier: AGPL-3.0-or-later
6
6
  #++
7
7
 
8
+ require_relative "sem_ver"
9
+
8
10
  # Represents a file that contains a version number.
9
11
  #
10
12
  # Gems have version numbers in multiple places: version.rb, Cargo.toml, etc.
@@ -16,27 +18,16 @@
16
18
  #
17
19
  # [path] The file path.
18
20
  # [pattern] A regex with lookarounds to match the version string.
19
- # [primary] Whether this is the primary source of truth.
20
21
  #
21
22
  # === Example
22
23
  #
23
24
  # manifest = Manifest.new(
24
25
  # path: "lib/my_gem/version.rb",
25
- # pattern: /(?<=VERSION = ")[^"]+(?=")/,
26
- # primary: true
26
+ # pattern: /(?<=VERSION = ")[^"]+(?=")/
27
27
  # )
28
28
  # manifest.version.to_s # => "1.2.3"
29
29
  #
30
- class Manifest < Data.define(:path, :pattern, :primary)
31
- # Creates a new Manifest.
32
- #
33
- # [path] The file path.
34
- # [pattern] A regex with lookarounds to match the version string.
35
- # [primary] Whether this is the primary source of truth.
36
- def initialize(path:, pattern:, primary: false)
37
- super
38
- end
39
-
30
+ class Manifest < Data.define(:path, :pattern)
40
31
  # Reads the file content.
41
32
  def read
42
33
  File.read(path)
@@ -44,11 +35,12 @@ class Manifest < Data.define(:path, :pattern, :primary)
44
35
 
45
36
  # Returns the current version from this manifest.
46
37
  def version
47
- content = read
48
- match = content.match(pattern)
49
- raise "Version missing in manifest #{path}" unless match
38
+ SemVer.parse(written)
39
+ end
50
40
 
51
- SemVer.parse(match[0])
41
+ # Returns the version exactly as this manifest writes it.
42
+ def written
43
+ read[pattern] or raise "Version missing in manifest #{path}"
52
44
  end
53
45
 
54
46
  # Writes a new version to this manifest.
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ # The Rust extension of a pure Ruby gem, which has none.
9
+ class NoCrate # :nodoc:
10
+ def manifests
11
+ []
12
+ end
13
+
14
+ def refresh
15
+ end
16
+
17
+ def release_native_gems(version:, sha:)
18
+ end
19
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ # The links of a changelog that has none yet, as the scaffold writes it.
9
+ class NoLinks # :nodoc:
10
+ def release(_version)
11
+ self
12
+ end
13
+
14
+ # With no links, the history runs to the end of the changelog.
15
+ def start_in(content)
16
+ content.length
17
+ end
18
+
19
+ def footer
20
+ ""
21
+ end
22
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ require_relative "bump_workflow"
9
+
10
+ # PatchRelease performs a patch release on the current release branch.
11
+ class PatchRelease < BumpWorkflow
12
+ private def assert_can_bump!
13
+ return unless @repository.on_trunk?
14
+
15
+ raise ArgumentError, "Cannot bump:#{@task} from trunk. Use a release branch."
16
+ end
17
+
18
+ private def prepare
19
+ puts "Bumping #{@task}: #{@gem.version} -> #{target}"
20
+ end
21
+
22
+ private def finalize
23
+ puts "\nCommitted. Push and run: bundle exec rake release"
24
+ end
25
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ # ReleaseBranch represents a release/X.Y branch for a version series.
9
+ class ReleaseBranch < Data.define(:major, :minor) # :nodoc:
10
+ def self.for_version(semver)
11
+ new(semver.major, semver.minor)
12
+ end
13
+
14
+ def name
15
+ "release/#{major}.#{minor}"
16
+ end
17
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ require_relative "bump_workflow"
9
+ require_relative "release_branch"
10
+
11
+ # ReleaseFromTrunk creates a release branch, releases there, syncs trunk, returns to release branch.
12
+ class ReleaseFromTrunk < BumpWorkflow
13
+ private def prepare
14
+ @branch = ReleaseBranch.for_version(target)
15
+
16
+ puts "Creating release branch: #{@branch.name}"
17
+ puts "Bumping #{@task}: #{@gem.version} -> #{target}"
18
+
19
+ @repository.create_branch(@branch.name)
20
+ end
21
+
22
+ private def release_on_branch
23
+ super
24
+ puts "\nCommitted on #{@branch.name}."
25
+
26
+ # Capture for trunk import
27
+ @released_changelog_content = File.read("CHANGELOG.md")
28
+ end
29
+
30
+ private def finalize
31
+ sync_trunk
32
+ return_to_release_branch
33
+ end
34
+
35
+ private def sync_trunk
36
+ @repository.checkout("trunk")
37
+ trunk_changelog = Changelog.new
38
+ trunk_changelog.import_release(target, @released_changelog_content)
39
+ @gem.update_version(target)
40
+ @repository.commit_all("chore: import v#{target} to trunk")
41
+ puts "Committed on trunk."
42
+ end
43
+
44
+ private def return_to_release_branch
45
+ @repository.checkout(@branch.name)
46
+ puts "\nBack on #{@branch.name}. Review, push both branches, then: bundle exec rake release"
47
+ end
48
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ require "open3"
9
+ require "rake/file_utils_ext"
10
+ require "tmpdir"
11
+
12
+ class Repository # :nodoc:
13
+ # Releasing shows each git command it runs, as rake tasks do.
14
+ include Rake::FileUtilsExt
15
+
16
+ TRUNK = "trunk"
17
+ STABLE = "stable"
18
+
19
+ class Author < Data.define(:name, :email) # :nodoc:
20
+ def to_s
21
+ "#{name} <#{email}>"
22
+ end
23
+ end
24
+
25
+ # The identity your next commit would carry, from git config or the
26
+ # GIT_AUTHOR_* variables. Without useConfigOnly, git would guess an email
27
+ # from the hostname, and that guess would end up in copyright headers.
28
+ def author
29
+ ident, status = Open3.capture2("git", "-c", "user.useConfigOnly=true", "var", "GIT_AUTHOR_IDENT", err: File::NULL)
30
+ name, email = ident.match(/\A(.+) <(.*)> \d+ [+-]\d{4}\n?\z/)&.captures
31
+ return Author.new(name:, email:) if status.success? && name && email
32
+
33
+ raise ArgumentError, "Git doesn't know who you are. Run: git config --global user.name \"Your Name\" " \
34
+ "and git config --global user.email you@example.com"
35
+ end
36
+
37
+ def on_trunk? = current_branch == TRUNK
38
+
39
+ def current_branch
40
+ git("branch", "--show-current").strip
41
+ end
42
+
43
+ def tracked_files(pattern)
44
+ git("ls-files", pattern).split("\n")
45
+ end
46
+
47
+ # The tracked files a syntax covers, under the given paths, or everywhere
48
+ # it looks by default. A path that isn't a directory is taken as it is, if
49
+ # it's written in the syntax. In a git pathspec * crosses directories, so
50
+ # dir/*.rb reaches every depth, while dir/**/*.rb would skip the files
51
+ # directly inside dir.
52
+ def tracked_files_for(paths, syntax)
53
+ return syntax.tracked_patterns.flat_map { |pattern| tracked_files(pattern) }.uniq if paths.empty?
54
+
55
+ paths.flat_map do |path|
56
+ if File.directory?(path)
57
+ syntax.extensions.flat_map { |extension| tracked_files("#{path}/*.#{extension}") }
58
+ elsif syntax.extensions.include?(File.extname(path).delete_prefix("."))
59
+ [path]
60
+ else
61
+ []
62
+ end
63
+ end
64
+ end
65
+
66
+ # Added, copied, modified, or renamed since HEAD, staged or not, plus new
67
+ # untracked files. The staged diff also covers a repository with no commits.
68
+ def changed_files
69
+ [
70
+ git("diff", "--name-only", "--diff-filter=ACMR", "HEAD"),
71
+ git("diff", "--name-only", "--cached", "--diff-filter=ACMR"),
72
+ git("ls-files", "--others", "--exclude-standard"),
73
+ ].flat_map { |output| output.split("\n") }.uniq
74
+ end
75
+
76
+ def latest_tag
77
+ git("describe", "--tags", "--abbrev=0").strip
78
+ end
79
+
80
+ # An annotated tag is an object of its own, and a branch can only point at
81
+ # a commit.
82
+ def released_commit(tag)
83
+ git("rev-parse", "#{tag}^{}").strip
84
+ end
85
+
86
+ # The file as the stable branch has it, or nil when there's no such branch
87
+ # or file.
88
+ def stable_file(path)
89
+ text = git("show", "#{STABLE}:#{path}")
90
+ text unless text.empty?
91
+ end
92
+
93
+ # Stable is reset to each release rather than fast-forwarded, so origin's
94
+ # copy is overwritten. Git can't tell that a bare name is meant as a branch
95
+ # when origin has none yet, as after the scaffold, so the push names it in
96
+ # full.
97
+ def move_stable(commit)
98
+ sh "git", "branch", "-f", STABLE, commit
99
+ sh "git", "push", "--force", "origin", "#{commit}:refs/heads/#{STABLE}"
100
+ end
101
+
102
+ # Deletes the tag here and on origin.
103
+ def delete_tag(name)
104
+ sh "git", "tag", "-d", name
105
+ sh "git", "push", "origin", ":refs/tags/#{name}"
106
+ end
107
+
108
+ # Tags HEAD here and on origin.
109
+ def tag_head(name)
110
+ sh "git", "tag", name, "HEAD"
111
+ sh "git", "push", "origin", name
112
+ end
113
+
114
+ def create_branch(name)
115
+ system("git", "checkout", "-b", name, exception: true)
116
+ end
117
+
118
+ def checkout(name)
119
+ system("git", "checkout", name, exception: true)
120
+ end
121
+
122
+ def assert_pristine!
123
+ return if git("status", "--porcelain").strip.empty?
124
+
125
+ raise ArgumentError, "Working tree is not clean. Commit or stash changes first."
126
+ end
127
+
128
+ def commit_all(message)
129
+ msg_file = File.join(Dir.tmpdir, "ratatui_ruby_commit_msg_#{$$}.txt")
130
+ system("git", "add", "-A", exception: true)
131
+ File.write(msg_file, message)
132
+ system("git", "commit", "-F", msg_file, exception: true)
133
+ ensure
134
+ File.delete(msg_file) if msg_file && File.exist?(msg_file)
135
+ end
136
+
137
+ # Runs git directly rather than through a shell, whose quoting and
138
+ # redirection differ on Windows, and keeps its complaints out of the
139
+ # output it answers with.
140
+ private def git(*)
141
+ Open3.capture2("git", *, err: File::NULL).first
142
+ end
143
+ end
@@ -5,73 +5,79 @@
5
5
  # SPDX-License-Identifier: AGPL-3.0-or-later
6
6
  #++
7
7
 
8
- # Coordinates version bumping across multiple manifests.
9
- #
10
- # Ruby gems have versions in multiple files: version.rb, Cargo.toml, lockfiles.
11
- # Bumping manually leads to mismatches. Forgetting the changelog produces
12
- # incomplete releases.
13
- #
14
- # This class orchestrates the bump. It updates all manifests, refreshes
15
- # lockfiles, and updates the changelog. One command. Consistent versions.
16
- #
17
- # Use it in rake tasks to bump major, minor, or patch versions.
18
- class RubyGem
19
- # Creates a new RubyGem coordinator.
20
- #
21
- # [manifests] Array of Manifest objects. Exactly one must be primary.
22
- # [lockfile] A lockfile object that responds to <tt>refresh</tt>.
23
- # [changelog] A Changelog object for updating release notes.
24
- def initialize(manifests:, lockfile:, changelog:)
25
- raise ArgumentError, "Must have exactly one primary manifest" unless manifests.count(&:primary) == 1
26
- @manifests = manifests
27
- @lockfile = lockfile
28
- @changelog = changelog
8
+ require "rubygems"
9
+ require_relative "manifest"
10
+ require_relative "rust_crate"
11
+
12
+ # A gem, found from its files wherever you haven't said otherwise.
13
+ class RubyGem < Data.define(:name, :gemspec_file, :version_file) # :nodoc:
14
+ VERSION = /(?<=VERSION = ")[^"]+(?=")/
15
+
16
+ def self.found(gemspec_file: nil, name: nil, version_file: nil)
17
+ gemspec_file ||= only_gemspec
18
+ name ||= File.basename(gemspec_file, ".gemspec")
19
+ new(name:, gemspec_file:, version_file: version_file || version_file_of(name))
20
+ end
21
+
22
+ def self.only_gemspec
23
+ gemspecs = Dir.glob("*.gemspec")
24
+ raise RatatuiRuby::Devtools::Error, "No *.gemspec found in #{Dir.pwd}" if gemspecs.empty?
25
+ raise RatatuiRuby::Devtools::Error, "Multiple gemspecs found: #{gemspecs.join(', ')}" if gemspecs.size > 1
26
+
27
+ gemspecs.first
28
+ end
29
+
30
+ # A gem named my_gem-kit keeps its version in lib/my_gem/kit/.
31
+ def self.version_file_of(name)
32
+ path = "lib/#{name.tr('-', '/')}/version.rb"
33
+ return path if File.exist?(path)
34
+
35
+ Dir.glob("lib/**/version.rb").first || path
36
+ end
37
+
38
+ # RubyGems caches a gemspec by the path it was loaded from, so a relative
39
+ # path would return another gem's spec loaded from another directory.
40
+ def spec
41
+ Gem::Specification.load(File.expand_path(gemspec_file))
42
+ end
43
+
44
+ def crate
45
+ RustCrate.of(name)
29
46
  end
30
47
 
31
- # Returns the current version from the primary manifest.
32
48
  def version
33
- @manifests.find(&:primary).version
49
+ manifest.version
50
+ end
51
+
52
+ # Bundler tags a release with the version as written, and the release
53
+ # tasks look for its SemVer tag. A bump reads 1.2 as 1.2.0 because it
54
+ # rewrites the file, but a release tags the file as it stands.
55
+ def release_version
56
+ written = manifest.written
57
+ version.tap do |full|
58
+ raise ArgumentError, "#{version_file} says #{written}. Write it as #{full} before releasing." if full.to_s != written
59
+ end
60
+ end
61
+
62
+ # What the stable branch released, or nil before the first release. Stable
63
+ # can still hold a version from before the gem used SemVer, which only
64
+ # RubyGems reads.
65
+ def stable_version(repository)
66
+ written = repository.stable_file(version_file)&.[](VERSION)
67
+ Gem::Version.new(written) if written
34
68
  end
35
69
 
36
- # Bumps the version by the given segment.
37
- #
38
- # Updates all manifests, refreshes lockfiles, and updates the changelog.
39
- # Prints a suggested commit message.
40
- #
41
- # [segment] One of <tt>:major</tt>, <tt>:minor</tt>, or <tt>:patch</tt>.
42
- def bump(segment)
43
- target = version.next(segment)
44
- commit_message = @changelog.commit_message(target)
45
-
46
- puts "Bumping #{segment}: #{version} -> #{target}"
47
- @changelog.release(target)
48
- @manifests.each { |manifest| manifest.write(target) }
49
- @lockfile.refresh
50
-
51
- puts_commit_message(commit_message)
70
+ def update_version(target)
71
+ [manifest, *crate.manifests].each { |manifest| manifest.write(target) }
72
+ crate.refresh
73
+ refresh_bundler_lockfile
52
74
  end
53
75
 
54
- # Sets the version to an exact value.
55
- #
56
- # Updates all manifests, refreshes lockfiles, and updates the changelog.
57
- # Prints a suggested commit message.
58
- #
59
- # [version_string] A version string like <tt>"1.2.3"</tt>.
60
- def set(version_string)
61
- target = SemVer.parse(version_string)
62
- commit_message = @changelog.commit_message(target)
63
-
64
- puts "Setting version: #{version} -> #{target}"
65
- @changelog.release(target)
66
- @manifests.each { |manifest| manifest.write(target) }
67
- @lockfile.refresh
68
-
69
- puts_commit_message(commit_message)
76
+ private def manifest
77
+ Manifest.new(path: version_file, pattern: VERSION)
70
78
  end
71
79
 
72
- private def puts_commit_message(message)
73
- puts "=" * 80
74
- puts message
75
- puts "=" * 80
80
+ private def refresh_bundler_lockfile
81
+ system("bundle install", exception: true)
76
82
  end
77
83
  end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ require_relative "cargo_lockfile"
9
+ require_relative "manifest"
10
+ require_relative "no_crate"
11
+ require_relative "../release/native_gem_version"
12
+
13
+ # A gem's Rust extension. The scaffold names its directory and package after
14
+ # the gem with underscores, as Cargo wants; older gems used the gem's name.
15
+ class RustCrate < Data.define(:dir) # :nodoc:
16
+ VERSION = /(?<=^version = ")[^"]+(?=")/
17
+
18
+ def self.of(gem_name)
19
+ dir = [gem_name.tr("-", "_"), gem_name].map { |name| "ext/#{name}" }.find { |path| File.exist?("#{path}/Cargo.toml") }
20
+ dir ? new(dir:) : NoCrate.new
21
+ end
22
+
23
+ def manifests
24
+ [Manifest.new(path: "#{dir}/Cargo.toml", pattern: VERSION)]
25
+ end
26
+
27
+ def refresh
28
+ lockfile.refresh
29
+ end
30
+
31
+ def lockfile
32
+ CargoLockfile.new(path: "#{dir}/Cargo.lock", dir:, name: package)
33
+ end
34
+
35
+ def release_native_gems(version:, sha:)
36
+ NativeGemVersion.new(version:, sha:).release
37
+ end
38
+
39
+ private def package
40
+ File.read("#{dir}/Cargo.toml")[/^name = "([^"]+)"/, 1]
41
+ end
42
+ end