semverve 0.1.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.
@@ -0,0 +1,370 @@
1
+ # frozen_string_literal: true
2
+
3
+ # TODO: Turn this into its own gem.
4
+
5
+ require "fileutils"
6
+ require "open3"
7
+ require "tmpdir"
8
+
9
+ require_relative "error"
10
+
11
+ module Semverve
12
+ ##
13
+ # Publishes generated documentation to a Git branch through a temporary
14
+ # worktree.
15
+ class DocsPublisher
16
+ ##
17
+ # Small command runner used by the publisher.
18
+ class Shell
19
+ ##
20
+ # Runs a command and raises when it fails.
21
+ #
22
+ # @param [Array<String>] command
23
+ # @param [String, nil] chdir
24
+ #
25
+ # @return [String]
26
+ def run(command, chdir: nil)
27
+ stdout, stderr, status = capture_command(command, chdir: chdir)
28
+ return stdout if status.success?
29
+
30
+ raise Error, "Command failed: #{command.join(" ")}\n#{stderr}"
31
+ end
32
+
33
+ ##
34
+ # Captures a command's standard output and raises when it fails.
35
+ #
36
+ # @param [Array<String>] command
37
+ # @param [String, nil] chdir
38
+ #
39
+ # @return [String]
40
+ def capture(command, chdir: nil)
41
+ run(command, chdir: chdir)
42
+ end
43
+
44
+ ##
45
+ # Whether a command exits successfully.
46
+ #
47
+ # @param [Array<String>] command
48
+ # @param [String, nil] chdir
49
+ #
50
+ # @return [Boolean]
51
+ def success?(command, chdir: nil)
52
+ _stdout, _stderr, status = capture_command(command, chdir: chdir)
53
+ status.success?
54
+ end
55
+
56
+ private
57
+
58
+ ##
59
+ # Captures a command, omitting +chdir+ when none was provided.
60
+ #
61
+ # @param [Array<String>] command
62
+ # @param [String, nil] chdir
63
+ #
64
+ # @return [Array(String, String, Process::Status)]
65
+ def capture_command(command, chdir:)
66
+ options = chdir ? {chdir: chdir} : {}
67
+
68
+ Open3.capture3(*command, **options)
69
+ end
70
+ end
71
+
72
+ ##
73
+ # Source project root.
74
+ #
75
+ # @return [String]
76
+ attr_accessor :root
77
+
78
+ ##
79
+ # Directory containing generated documentation, relative to +root+.
80
+ #
81
+ # @return [String]
82
+ attr_accessor :source_dir
83
+
84
+ ##
85
+ # Documentation directory on the publishing branch.
86
+ #
87
+ # @return [String]
88
+ attr_accessor :target_dir
89
+
90
+ ##
91
+ # Branch that receives generated documentation.
92
+ #
93
+ # @return [String]
94
+ attr_accessor :branch
95
+
96
+ ##
97
+ # Remote used when pushing the publishing branch.
98
+ #
99
+ # @return [String]
100
+ attr_accessor :remote
101
+
102
+ ##
103
+ # Commit message for generated documentation updates.
104
+ #
105
+ # @return [String]
106
+ attr_accessor :commit_message
107
+
108
+ ##
109
+ # Optional path for the temporary worktree.
110
+ #
111
+ # @return [String, nil]
112
+ attr_accessor :worktree_path
113
+
114
+ ##
115
+ # Whether dirty source working trees are allowed.
116
+ #
117
+ # @return [Boolean]
118
+ attr_accessor :allow_dirty
119
+
120
+ ##
121
+ # Whether the publishing branch should be pushed.
122
+ #
123
+ # @return [Boolean]
124
+ attr_accessor :push
125
+
126
+ ##
127
+ # Whether to report changes without committing or pushing.
128
+ #
129
+ # @return [Boolean]
130
+ attr_accessor :dry_run
131
+
132
+ ##
133
+ # Command runner used for Git commands.
134
+ #
135
+ # @return [#run, #capture, #success?]
136
+ attr_accessor :command_runner
137
+
138
+ ##
139
+ # Output stream for status messages.
140
+ #
141
+ # @return [#puts]
142
+ attr_accessor :output
143
+
144
+ ##
145
+ # Initializes a documentation publisher.
146
+ #
147
+ # @yieldparam [Semverve::DocsPublisher] publisher
148
+ #
149
+ # @return [Semverve::DocsPublisher]
150
+ def initialize
151
+ @root = Dir.pwd
152
+ @source_dir = "docs"
153
+ @target_dir = "docs"
154
+ @branch = "gh-pages"
155
+ @remote = "origin"
156
+ @commit_message = "Update generated documentation"
157
+ @worktree_path = nil
158
+ @allow_dirty = false
159
+ @push = true
160
+ @dry_run = false
161
+ @command_runner = Shell.new
162
+ @output = $stdout
163
+
164
+ yield self if block_given?
165
+ end
166
+
167
+ ##
168
+ # Publishes generated documentation.
169
+ #
170
+ # @return [Boolean] whether documentation changes were found
171
+ def publish
172
+ validate!
173
+ ensure_clean_source_worktree unless allow_dirty
174
+
175
+ with_worktree do |worktree|
176
+ sync_docs_to(worktree)
177
+
178
+ unless publishing_worktree_changed?(worktree)
179
+ output.puts "Documentation is already current on #{branch}."
180
+ return false
181
+ end
182
+
183
+ if dry_run
184
+ output.puts "Documentation changes detected for #{branch}; dry run did not commit or push."
185
+ return true
186
+ end
187
+
188
+ commit_docs(worktree)
189
+ push_docs(worktree) if push
190
+ output.puts "Published documentation to #{remote}/#{branch}."
191
+ true
192
+ end
193
+ end
194
+
195
+ private
196
+
197
+ ##
198
+ # Validates publishing configuration.
199
+ #
200
+ # @return [void]
201
+ def validate!
202
+ raise Error, "Documentation source directory does not exist: #{source_path}." unless File.directory?(source_path)
203
+
204
+ if target_dir.nil? || target_dir.empty? || target_dir == "." || target_dir.start_with?("/")
205
+ raise Error, "target_dir must be a relative directory such as \"docs\"."
206
+ end
207
+ end
208
+
209
+ ##
210
+ # Absolute source project root.
211
+ #
212
+ # @return [String]
213
+ def source_root
214
+ @source_root ||= File.expand_path(root)
215
+ end
216
+
217
+ ##
218
+ # Absolute source documentation directory.
219
+ #
220
+ # @return [String]
221
+ def source_path
222
+ File.expand_path(source_dir, source_root)
223
+ end
224
+
225
+ ##
226
+ # Ensures the source working tree is clean.
227
+ #
228
+ # @return [void]
229
+ def ensure_clean_source_worktree
230
+ status = git_capture(source_root, "status", "--porcelain")
231
+ return if status.empty?
232
+
233
+ raise Error, "Working tree must be clean before publishing documentation. Commit, stash, or set allow_dirty."
234
+ end
235
+
236
+ ##
237
+ # Yields a temporary publishing worktree and removes it afterward.
238
+ #
239
+ # @yieldparam [String] worktree
240
+ #
241
+ # @return [Object]
242
+ def with_worktree
243
+ temporary_path = worktree_path || Dir.mktmpdir("semverve-docs-publish-")
244
+ temporary_worktree = worktree_path.nil?
245
+ worktree_added = false
246
+
247
+ if temporary_worktree
248
+ FileUtils.rm_rf(temporary_path)
249
+ elsif File.exist?(temporary_path)
250
+ raise Error, "Worktree path already exists: #{temporary_path}."
251
+ end
252
+
253
+ add_worktree(temporary_path)
254
+ worktree_added = true
255
+ yield temporary_path
256
+ ensure
257
+ remove_worktree(temporary_path) if temporary_path && worktree_added
258
+ FileUtils.rm_rf(temporary_path) if temporary_path && temporary_worktree
259
+ end
260
+
261
+ ##
262
+ # Adds a worktree for the publishing branch.
263
+ #
264
+ # @param [String] path
265
+ #
266
+ # @return [void]
267
+ def add_worktree(path)
268
+ if local_branch?
269
+ git_run(source_root, "worktree", "add", path, branch)
270
+ elsif remote_branch?
271
+ git_run(source_root, "worktree", "add", "-b", branch, path, "#{remote}/#{branch}")
272
+ else
273
+ raise Error, "Could not find #{branch} locally or at #{remote}/#{branch}."
274
+ end
275
+ end
276
+
277
+ ##
278
+ # Removes a worktree.
279
+ #
280
+ # @param [String] path
281
+ #
282
+ # @return [void]
283
+ def remove_worktree(path)
284
+ git_run(source_root, "worktree", "remove", "--force", path) if File.directory?(path)
285
+ end
286
+
287
+ ##
288
+ # Whether the publishing branch exists locally.
289
+ #
290
+ # @return [Boolean]
291
+ def local_branch?
292
+ command_runner.success?(["git", "show-ref", "--verify", "--quiet", "refs/heads/#{branch}"], chdir: source_root)
293
+ end
294
+
295
+ ##
296
+ # Whether the publishing branch exists as a remote-tracking branch.
297
+ #
298
+ # @return [Boolean]
299
+ def remote_branch?
300
+ command_runner.success?(["git", "show-ref", "--verify", "--quiet", "refs/remotes/#{remote}/#{branch}"], chdir: source_root)
301
+ end
302
+
303
+ ##
304
+ # Copies generated documentation into the publishing worktree.
305
+ #
306
+ # @param [String] worktree
307
+ #
308
+ # @return [void]
309
+ def sync_docs_to(worktree)
310
+ target_path = File.expand_path(target_dir, worktree)
311
+
312
+ FileUtils.rm_rf(target_path)
313
+ FileUtils.mkdir_p(File.dirname(target_path))
314
+ FileUtils.cp_r(source_path, target_path)
315
+ end
316
+
317
+ ##
318
+ # Whether the publishing worktree has documentation changes.
319
+ #
320
+ # @param [String] worktree
321
+ #
322
+ # @return [Boolean]
323
+ def publishing_worktree_changed?(worktree)
324
+ !git_capture(worktree, "status", "--porcelain", "--", target_dir).empty?
325
+ end
326
+
327
+ ##
328
+ # Commits documentation changes in the publishing worktree.
329
+ #
330
+ # @param [String] worktree
331
+ #
332
+ # @return [void]
333
+ def commit_docs(worktree)
334
+ git_run(worktree, "add", target_dir)
335
+ git_run(worktree, "commit", "-m", commit_message)
336
+ end
337
+
338
+ ##
339
+ # Pushes documentation changes.
340
+ #
341
+ # @param [String] worktree
342
+ #
343
+ # @return [void]
344
+ def push_docs(worktree)
345
+ git_run(worktree, "push", remote, branch)
346
+ end
347
+
348
+ ##
349
+ # Runs a Git command in a directory.
350
+ #
351
+ # @param [String] directory
352
+ # @param [Array<String>] arguments
353
+ #
354
+ # @return [String]
355
+ def git_run(directory, *arguments)
356
+ command_runner.run(["git", *arguments], chdir: directory)
357
+ end
358
+
359
+ ##
360
+ # Captures a Git command in a directory.
361
+ #
362
+ # @param [String] directory
363
+ # @param [Array<String>] arguments
364
+ #
365
+ # @return [String]
366
+ def git_capture(directory, *arguments)
367
+ command_runner.capture(["git", *arguments], chdir: directory)
368
+ end
369
+ end
370
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Semverve
4
+ ##
5
+ # Base exception for Semverve-specific failures.
6
+ class Error < StandardError
7
+ end
8
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Semverve
4
+ ##
5
+ # Resolves configured Rake::FileList entries relative to a project root.
6
+ class FileListResolver
7
+ ##
8
+ # Initializes file-list resolution.
9
+ #
10
+ # @param [String] root
11
+ # @param [Rake::FileList] file_list
12
+ #
13
+ # @return [Semverve::FileListResolver]
14
+ def initialize(root:, file_list:)
15
+ @root = root
16
+ @file_list = file_list
17
+ end
18
+
19
+ ##
20
+ # Absolute files from the configured file list.
21
+ #
22
+ # @return [Array<String>]
23
+ def files
24
+ configured_files.map { |path| File.expand_path(path, root) }
25
+ .select { |path| File.file?(path) }
26
+ .uniq
27
+ end
28
+
29
+ private
30
+
31
+ ##
32
+ # Absolute project root.
33
+ #
34
+ # @return [String]
35
+ attr_reader :root
36
+
37
+ ##
38
+ # Configured Rake file list.
39
+ #
40
+ # @return [Rake::FileList]
41
+ attr_reader :file_list
42
+
43
+ ##
44
+ # Configured files expanded relative to the root.
45
+ #
46
+ # @return [Array<String>]
47
+ def configured_files
48
+ Dir.chdir(root) do
49
+ file_list.to_a.flat_map { |path| expand_file_list_entry(path) }
50
+ .reject { |path| file_list.excluded_from_list?(path) }
51
+ end
52
+ end
53
+
54
+ ##
55
+ # Expands glob-like entries that were appended to a Rake::FileList.
56
+ #
57
+ # @param [String] path
58
+ #
59
+ # @return [Array<String>]
60
+ def expand_file_list_entry(path)
61
+ matches = Dir.glob(path)
62
+
63
+ matches.empty? ? [path] : matches
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../error"
4
+ require_relative "../semantic_version"
5
+
6
+ module Semverve
7
+ module Formats
8
+ ##
9
+ # Handles version files with MAJOR, MINOR, and PATCH constants.
10
+ class ModuleConstants
11
+ ##
12
+ # Mapping of semantic-version parts to Ruby constant names.
13
+ #
14
+ # @return [Hash<Symbol, String>]
15
+ CONSTANTS = {
16
+ major: "MAJOR",
17
+ minor: "MINOR",
18
+ patch: "PATCH"
19
+ }.freeze
20
+
21
+ ##
22
+ # Parses a semantic version from module-constant content.
23
+ #
24
+ # @param [String] content
25
+ # @param [String] path
26
+ #
27
+ # @return [Semverve::SemanticVersion]
28
+ def parse(content, path:)
29
+ SemanticVersion.new(
30
+ major: constant_value(content, path, :major),
31
+ minor: constant_value(content, path, :minor),
32
+ patch: constant_value(content, path, :patch)
33
+ )
34
+ end
35
+
36
+ ##
37
+ # Replaces MAJOR, MINOR, and PATCH values in module-constant content.
38
+ #
39
+ # @param [String] content
40
+ # @param [Semverve::SemanticVersion] version
41
+ # @param [String] path
42
+ #
43
+ # @return [String]
44
+ def replace(content, version, path:)
45
+ CONSTANTS.reduce(content) do |updated, (part, constant)|
46
+ pattern = /^(\s*#{constant}\s*=\s*)\d+/
47
+ value = version.public_send(part)
48
+
49
+ unless updated.match?(pattern)
50
+ raise Error, "Could not find #{constant} in #{path}."
51
+ end
52
+
53
+ updated.sub(pattern) { "#{$1}#{value}" }
54
+ end
55
+ end
56
+
57
+ ##
58
+ # Generates a module-constant version file.
59
+ #
60
+ # @param [Semverve::SemanticVersion] version
61
+ # @param [String] module_name
62
+ #
63
+ # @return [String]
64
+ def generate(version, module_name:)
65
+ <<~RUBY
66
+ # frozen_string_literal: true
67
+
68
+ ##
69
+ # Namespace for #{module_name}.
70
+ module #{module_name}
71
+ ##
72
+ # Semantic version information for #{module_name}.
73
+ module Version
74
+ ##
75
+ # Major version.
76
+ #
77
+ # @return [Integer]
78
+ MAJOR = #{version.major}
79
+
80
+ ##
81
+ # Minor version.
82
+ #
83
+ # @return [Integer]
84
+ MINOR = #{version.minor}
85
+
86
+ ##
87
+ # Patch version.
88
+ #
89
+ # @return [Integer]
90
+ PATCH = #{version.patch}
91
+
92
+ module_function
93
+
94
+ ##
95
+ # Version as +[MAJOR, MINOR, PATCH]+
96
+ #
97
+ # @return [Array<Integer>]
98
+ def to_a
99
+ [MAJOR, MINOR, PATCH]
100
+ end
101
+
102
+ ##
103
+ # Version as +MAJOR.MINOR.PATCH+
104
+ #
105
+ # @return [String]
106
+ def to_s
107
+ to_a.join(".")
108
+ end
109
+ end
110
+
111
+ ##
112
+ # Full gem version string.
113
+ #
114
+ # @return [String]
115
+ VERSION = #{module_name}::Version.to_s
116
+ end
117
+ RUBY
118
+ end
119
+
120
+ private
121
+
122
+ ##
123
+ # Extracts one semantic-version constant from content.
124
+ #
125
+ # @param [String] content
126
+ # @param [String] path
127
+ # @param [Symbol] part
128
+ #
129
+ # @return [Integer]
130
+ def constant_value(content, path, part)
131
+ constant = CONSTANTS.fetch(part)
132
+ match = content.match(/^\s*#{constant}\s*=\s*(\d+)/)
133
+
134
+ return match[1].to_i if match
135
+
136
+ raise Error, "Could not parse #{path} as module format. Expected #{constant} = <integer>."
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../error"
4
+ require_relative "../semantic_version"
5
+
6
+ module Semverve
7
+ module Formats
8
+ ##
9
+ # Handles version files that define a single top-level +VERSION+ string.
10
+ class SimpleString
11
+ ##
12
+ # Pattern for locating a simple +VERSION+ assignment.
13
+ #
14
+ # @return [Regexp]
15
+ PATTERN = /^(\s*VERSION\s*=\s*)(["'])(\d+\.\d+\.\d+)(\2)/
16
+
17
+ ##
18
+ # Parses a semantic version from simple string content.
19
+ #
20
+ # @param [String] content
21
+ # @param [String] path
22
+ #
23
+ # @return [Semverve::SemanticVersion]
24
+ def parse(content, path:)
25
+ match = content.match(PATTERN)
26
+
27
+ return SemanticVersion.parse(match[3]) if match
28
+
29
+ raise Error, "Could not parse #{path} as simple format. Expected VERSION = \"MAJOR.MINOR.PATCH\"."
30
+ end
31
+
32
+ ##
33
+ # Replaces the semantic version in simple string content.
34
+ #
35
+ # @param [String] content
36
+ # @param [Semverve::SemanticVersion] version
37
+ # @param [String] path
38
+ #
39
+ # @return [String]
40
+ def replace(content, version, path:)
41
+ unless content.match?(PATTERN)
42
+ raise Error, "Could not parse #{path} as simple format. Expected VERSION = \"MAJOR.MINOR.PATCH\"."
43
+ end
44
+
45
+ content.sub(PATTERN) { "#{$1}#{$2}#{version}#{$4}" }
46
+ end
47
+
48
+ ##
49
+ # Generates a simple version file.
50
+ #
51
+ # @param [Semverve::SemanticVersion] version
52
+ # @param [String] module_name
53
+ #
54
+ # @return [String]
55
+ def generate(version, module_name:)
56
+ <<~RUBY
57
+ # frozen_string_literal: true
58
+
59
+ ##
60
+ # Namespace for #{module_name}.
61
+ module #{module_name}
62
+ ##
63
+ # Full gem version string.
64
+ #
65
+ # @return [String]
66
+ VERSION = "#{version}"
67
+ end
68
+ RUBY
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "formats/module_constants"
4
+ require_relative "formats/simple_string"
5
+
6
+ module Semverve
7
+ ##
8
+ # Format handlers for parsing, replacing, and generating version files.
9
+ module Formats
10
+ ##
11
+ # Returns the handler for a configured version-file format.
12
+ #
13
+ # @param [Symbol, String] name
14
+ #
15
+ # @return [Semverve::Formats::ModuleConstants, Semverve::Formats::SimpleString]
16
+ def self.fetch(name)
17
+ case name.to_sym
18
+ when :module
19
+ ModuleConstants.new
20
+ when :simple
21
+ SimpleString.new
22
+ else
23
+ raise Error, "Unknown version format #{name.inspect}. Use :module or :simple."
24
+ end
25
+ end
26
+ end
27
+ end