semverve 0.3.0 → 0.4.1

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.
@@ -1,370 +0,0 @@
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