spawnpoint 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.
- checksums.yaml +7 -0
- data/.gitignore +5 -0
- data/Gemfile +8 -0
- data/LICENSE +21 -0
- data/README.md +153 -0
- data/Rakefile +10 -0
- data/docs/2026-08-14-spawnpoint-gem-design.md +90 -0
- data/docs/2026-08-15-spawnpoint-gem-plan.md +1223 -0
- data/exe/spwn +6 -0
- data/lib/spawnpoint/cli.rb +342 -0
- data/lib/spawnpoint/synchronizer.rb +244 -0
- data/lib/spawnpoint/version.rb +5 -0
- data/lib/spawnpoint.rb +4 -0
- data/spawnpoint.gemspec +26 -0
- data/test/test_cli.rb +38 -0
- data/test/test_helper.rb +2 -0
- data/test/test_synchronizer.rb +95 -0
- data/test/test_version.rb +12 -0
- metadata +59 -0
|
@@ -0,0 +1,1223 @@
|
|
|
1
|
+
# Package spwn as the `spawnpoint` Gem — Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Restructure the spwn script into the publishable `spawnpoint` Ruby gem (executable `spwn`), fixing the exit-code, marker-merge, and `Syncronizer` naming bugs on the way.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Standard gem layout: `exe/spwn` is a two-line executable; all logic moves from `bin/spwn` and `bin/__sync__.rb` into `lib/spawnpoint/*.rb` under the `Spawnpoint` namespace. Tests use minitest (stdlib).
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Ruby >= 3.0, rubygems, minitest, rake. No runtime dependencies.
|
|
10
|
+
|
|
11
|
+
**Spec:** `docs/2026-08-14-spawnpoint-gem-design.md`
|
|
12
|
+
|
|
13
|
+
## Global Constraints
|
|
14
|
+
|
|
15
|
+
- Gem name is `spawnpoint`; the installed executable is `spwn`.
|
|
16
|
+
- Module namespace is `Spawnpoint` everywhere (`Spawnpoint::CLI`, `Spawnpoint::Synchronizer`, `Spawnpoint::VERSION`).
|
|
17
|
+
- `Spawnpoint::VERSION` is `"0.2.0"`.
|
|
18
|
+
- No runtime dependencies — Ruby stdlib only (`fileutils`, `pathname`, `set`).
|
|
19
|
+
- All user-facing messages keep the existing child-friendly voice ("Oops: ...").
|
|
20
|
+
- Handler return contract: a handler returns an Integer exit code, or `true`/`false` from `system`; the dispatcher normalizes (`true`→0, `false`/`nil`→1) and `Spawnpoint::CLI.run` returns it.
|
|
21
|
+
- Do not commit the untracked pipeline scaffolding (`plan.json`, `pipeline-config.json`, etc.).
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
### Task 1: Gem skeleton and version
|
|
26
|
+
|
|
27
|
+
**Files:**
|
|
28
|
+
- Create: `spawnpoint.gemspec`
|
|
29
|
+
- Create: `Gemfile`
|
|
30
|
+
- Create: `Rakefile`
|
|
31
|
+
- Create: `LICENSE`
|
|
32
|
+
- Modify: `.gitignore`
|
|
33
|
+
- Create: `lib/spawnpoint/version.rb`
|
|
34
|
+
- Create: `test/test_helper.rb`
|
|
35
|
+
- Create: `test/test_version.rb`
|
|
36
|
+
|
|
37
|
+
**Interfaces:**
|
|
38
|
+
- Produces: `Spawnpoint::VERSION` (String, `"0.2.0"`), required via `require "spawnpoint/version"`. Tasks 2–4 and the gemspec depend on it.
|
|
39
|
+
|
|
40
|
+
- [ ] **Step 1: Write the failing test**
|
|
41
|
+
|
|
42
|
+
`test/test_helper.rb`:
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
$LOAD_PATH.unshift File.expand_path("../lib", __dir__)
|
|
46
|
+
require "minitest/autorun"
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`test/test_version.rb`:
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
require "test_helper"
|
|
53
|
+
require "spawnpoint/version"
|
|
54
|
+
|
|
55
|
+
class TestVersion < Minitest::Test
|
|
56
|
+
def test_version_format
|
|
57
|
+
assert_match(/\A\d+\.\d+\.\d+\z/, Spawnpoint::VERSION)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def test_version_is_0_2_0
|
|
61
|
+
assert_equal "0.2.0", Spawnpoint::VERSION
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
67
|
+
|
|
68
|
+
Run: `ruby -Itest test/test_version.rb`
|
|
69
|
+
Expected: FAIL with `cannot load such file -- spawnpoint/version` (LoadError)
|
|
70
|
+
|
|
71
|
+
- [ ] **Step 3: Write minimal implementation**
|
|
72
|
+
|
|
73
|
+
`lib/spawnpoint/version.rb`:
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
# frozen_string_literal: true
|
|
77
|
+
|
|
78
|
+
module Spawnpoint
|
|
79
|
+
VERSION = "0.2.0"
|
|
80
|
+
end
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
84
|
+
|
|
85
|
+
Run: `ruby -Itest test/test_version.rb`
|
|
86
|
+
Expected: PASS — 2 runs, 2 assertions, 0 failures
|
|
87
|
+
|
|
88
|
+
- [ ] **Step 5: Add gem packaging files**
|
|
89
|
+
|
|
90
|
+
`spawnpoint.gemspec`:
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
# frozen_string_literal: true
|
|
94
|
+
|
|
95
|
+
require_relative "lib/spawnpoint/version"
|
|
96
|
+
|
|
97
|
+
Gem::Specification.new do |spec|
|
|
98
|
+
spec.name = "spawnpoint"
|
|
99
|
+
spec.version = Spawnpoint::VERSION
|
|
100
|
+
spec.authors = ["bebekim"]
|
|
101
|
+
spec.summary = "A child-friendly Git mask for learning game programming"
|
|
102
|
+
spec.description = "spwn is a thin wrapper around Git that renames Git " \
|
|
103
|
+
"commands into friendlier, game-like language for kids " \
|
|
104
|
+
"learning game programming."
|
|
105
|
+
spec.homepage = "https://github.com/bebekim/spawnpoint"
|
|
106
|
+
spec.license = "MIT"
|
|
107
|
+
spec.required_ruby_version = ">= 3.0"
|
|
108
|
+
|
|
109
|
+
spec.files = Dir.chdir(__dir__) { `git ls-files -z`.split("\x0") }
|
|
110
|
+
spec.bindir = "exe"
|
|
111
|
+
spec.executables = ["spwn"]
|
|
112
|
+
spec.require_paths = ["lib"]
|
|
113
|
+
|
|
114
|
+
spec.metadata = {
|
|
115
|
+
"source_code_uri" => "https://github.com/bebekim/spawnpoint",
|
|
116
|
+
"rubygems_mfa_required" => "true"
|
|
117
|
+
}
|
|
118
|
+
end
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
`Gemfile`:
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
# frozen_string_literal: true
|
|
125
|
+
|
|
126
|
+
source "https://rubygems.org"
|
|
127
|
+
|
|
128
|
+
gemspec
|
|
129
|
+
|
|
130
|
+
gem "minitest", "~> 5.0"
|
|
131
|
+
gem "rake", "~> 13.0"
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
`Rakefile`:
|
|
135
|
+
|
|
136
|
+
```ruby
|
|
137
|
+
# frozen_string_literal: true
|
|
138
|
+
|
|
139
|
+
require "rake/testtask"
|
|
140
|
+
|
|
141
|
+
Rake::TestTask.new(:test) do |t|
|
|
142
|
+
t.libs << "test" << "lib"
|
|
143
|
+
t.test_files = FileList["test/**/test_*.rb"]
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
task default: :test
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
`LICENSE` (MIT, year 2026, copyright holder "bebekim"):
|
|
150
|
+
|
|
151
|
+
```text
|
|
152
|
+
MIT License
|
|
153
|
+
|
|
154
|
+
Copyright (c) 2026 bebekim
|
|
155
|
+
|
|
156
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
157
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
158
|
+
in the Software without restriction, including without limitation the rights
|
|
159
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
160
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
161
|
+
furnished to do so, subject to the following conditions:
|
|
162
|
+
|
|
163
|
+
The above copyright notice and this permission notice shall be included in all
|
|
164
|
+
copies or substantial portions of the Software.
|
|
165
|
+
|
|
166
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
167
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
168
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
169
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
170
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
171
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
172
|
+
SOFTWARE.
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Append to `.gitignore` (keep the existing `.claude` / `.agent-learning` lines):
|
|
176
|
+
|
|
177
|
+
```text
|
|
178
|
+
*.gem
|
|
179
|
+
/pkg/
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
- [ ] **Step 6: Verify rake and gem build**
|
|
183
|
+
|
|
184
|
+
Run: `bundle install && rake`
|
|
185
|
+
Expected: PASS — version tests run green via rake
|
|
186
|
+
|
|
187
|
+
Run: `gem build spawnpoint.gemspec`
|
|
188
|
+
Expected: `Successfully built RubyGem` producing `spawnpoint-0.2.0.gem`. Note: `spec.executables` lists `spwn` which does not exist yet — that warning is fine at this point; Task 2 adds `exe/spwn`. Delete the built file afterwards: `rm spawnpoint-0.2.0.gem`.
|
|
189
|
+
|
|
190
|
+
- [ ] **Step 7: Commit**
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
git add spawnpoint.gemspec Gemfile Rakefile LICENSE .gitignore lib/spawnpoint/version.rb test/test_helper.rb test/test_version.rb
|
|
194
|
+
git commit -m "add gem skeleton with Spawnpoint::VERSION"
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
---
|
|
198
|
+
|
|
199
|
+
### Task 2: Move the CLI into `lib/spawnpoint/cli.rb`
|
|
200
|
+
|
|
201
|
+
**Files:**
|
|
202
|
+
- Create: `lib/spawnpoint/cli.rb`
|
|
203
|
+
- Create: `lib/spawnpoint.rb`
|
|
204
|
+
- Create: `exe/spwn`
|
|
205
|
+
- Create: `test/test_cli.rb`
|
|
206
|
+
- Reference (do not modify yet): `bin/spwn` — the code moves from here
|
|
207
|
+
|
|
208
|
+
**Interfaces:**
|
|
209
|
+
- Consumes: `Spawnpoint::VERSION` from Task 1.
|
|
210
|
+
- Produces: `Spawnpoint::CLI.run(argv)` → Integer exit code. This is the only public entry point; `exe/spwn` calls it. The `sync`/`rollback` handlers lazily `require_relative "synchronizer"` and call `Spawnpoint::Synchronizer.new.run(args)` / `.rollback(args)` — Task 3 provides that class.
|
|
211
|
+
|
|
212
|
+
- [ ] **Step 1: Write the failing tests**
|
|
213
|
+
|
|
214
|
+
`test/test_cli.rb`:
|
|
215
|
+
|
|
216
|
+
```ruby
|
|
217
|
+
require "test_helper"
|
|
218
|
+
require "spawnpoint/cli"
|
|
219
|
+
|
|
220
|
+
class TestCli < Minitest::Test
|
|
221
|
+
def run_cli(argv)
|
|
222
|
+
capture_io { @status = Spawnpoint::CLI.run(argv) }
|
|
223
|
+
@status
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def test_no_arguments_prints_help_and_exits_zero
|
|
227
|
+
assert_equal 0, run_cli([])
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def test_help_flag_exits_zero
|
|
231
|
+
assert_equal 0, run_cli(["--help"])
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def test_version_flag_exits_zero
|
|
235
|
+
out, = capture_io { Spawnpoint::CLI.run(["--version"]) }
|
|
236
|
+
assert_includes out, "0.2.0"
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def test_unknown_command_exits_one
|
|
240
|
+
assert_equal 1, run_cli(["teleport"])
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def test_save_without_message_exits_one
|
|
244
|
+
assert_equal 1, run_cli(["save"])
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def test_commit_without_message_exits_one
|
|
248
|
+
assert_equal 1, run_cli(["commit"])
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def test_hop_without_arguments_exits_one
|
|
252
|
+
assert_equal 1, run_cli(["hop"])
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
- [ ] **Step 2: Run tests to verify they fail**
|
|
258
|
+
|
|
259
|
+
Run: `ruby -Itest test/test_cli.rb`
|
|
260
|
+
Expected: FAIL with `cannot load such file -- spawnpoint/cli` (LoadError)
|
|
261
|
+
|
|
262
|
+
- [ ] **Step 3: Implement `lib/spawnpoint/cli.rb`**
|
|
263
|
+
|
|
264
|
+
This is the full content of `bin/spwn` reorganized into the `Spawnpoint::CLI` module, with the exit-code fix. Changes from `bin/spwn`: top-level methods become module functions (`extend self`); `VERSION` becomes `Spawnpoint::VERSION`; the sync/rollback handlers require `synchronizer` and use the renamed `Spawnpoint::Synchronizer`; `run` normalizes handler return values instead of always returning 0; the trailing `exit(run(ARGV))` script lines are gone (that moves to `exe/spwn`).
|
|
265
|
+
|
|
266
|
+
`lib/spawnpoint/cli.rb`:
|
|
267
|
+
|
|
268
|
+
```ruby
|
|
269
|
+
# frozen_string_literal: true
|
|
270
|
+
|
|
271
|
+
require_relative "version"
|
|
272
|
+
|
|
273
|
+
module Spawnpoint
|
|
274
|
+
# CLI implements the spwn command-line interface.
|
|
275
|
+
#
|
|
276
|
+
# It is a thin wrapper around Git. It renames Git commands into friendlier,
|
|
277
|
+
# game-like language and delegates the actual work to Git.
|
|
278
|
+
#
|
|
279
|
+
# The mapping from "spwn commands" to Git invocations lives in the
|
|
280
|
+
# COMMANDS table below so it is easy to extend without changing the
|
|
281
|
+
# dispatch logic.
|
|
282
|
+
module CLI
|
|
283
|
+
extend self
|
|
284
|
+
|
|
285
|
+
# ---------------------------------------------------------------------
|
|
286
|
+
# Helper methods
|
|
287
|
+
# ---------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
def git(*args)
|
|
290
|
+
system("git", *args)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def say(message)
|
|
294
|
+
puts message
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def error(message)
|
|
298
|
+
say("Oops: #{message}")
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def ask_yes_no(question)
|
|
302
|
+
loop do
|
|
303
|
+
print "#{question} (y/n): "
|
|
304
|
+
answer = STDIN.gets.to_s.strip.downcase
|
|
305
|
+
return true if answer == "y"
|
|
306
|
+
return false if answer == "n"
|
|
307
|
+
say("Please type y or n.")
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def extract_message_from_args(args)
|
|
312
|
+
message_parts = []
|
|
313
|
+
remaining = []
|
|
314
|
+
i = 0
|
|
315
|
+
while i < args.length
|
|
316
|
+
if args[i] == "-m" && i + 1 < args.length
|
|
317
|
+
message_parts << args[i + 1]
|
|
318
|
+
i += 2
|
|
319
|
+
elsif args[i] == "--message" && i + 1 < args.length
|
|
320
|
+
message_parts << args[i + 1]
|
|
321
|
+
i += 2
|
|
322
|
+
else
|
|
323
|
+
remaining << args[i]
|
|
324
|
+
i += 1
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
[message_parts.join(" "), remaining]
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def files_from_args(args, allow_all: false)
|
|
331
|
+
if allow_all && args.include?("-A")
|
|
332
|
+
["-A"]
|
|
333
|
+
elsif args.empty? || args.include?(".")
|
|
334
|
+
["."]
|
|
335
|
+
else
|
|
336
|
+
args
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def command_args_include_help?(args)
|
|
341
|
+
args.include?("--help") || args.include?("-h")
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def git_command_exists?(name)
|
|
345
|
+
# A quick probe using git's own error reporting.
|
|
346
|
+
system("git", name, "--help", out: "/dev/null", err: "/dev/null")
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def file_on_head?(path)
|
|
350
|
+
# Returns true when the given path is tracked and present on the current HEAD.
|
|
351
|
+
# We use git ls-files so the check respects the index/HEAD rather than the
|
|
352
|
+
# working tree alone.
|
|
353
|
+
system("git", "ls-files", "--error-unmatch", path, out: "/dev/null", err: "/dev/null")
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# ---------------------------------------------------------------------
|
|
357
|
+
# Command mapping table
|
|
358
|
+
#
|
|
359
|
+
# Each entry is a hash with:
|
|
360
|
+
# :name - the spwn subcommand students type
|
|
361
|
+
# :help - a short child-friendly description
|
|
362
|
+
# :usage - a short usage hint
|
|
363
|
+
# :handler - a callable that receives the remaining arguments
|
|
364
|
+
#
|
|
365
|
+
# Keeping this table in one place is deliberate: it is the single place
|
|
366
|
+
# to look when you want to add or change a command.
|
|
367
|
+
# ---------------------------------------------------------------------
|
|
368
|
+
|
|
369
|
+
COMMANDS = {
|
|
370
|
+
init: {
|
|
371
|
+
name: "init",
|
|
372
|
+
help: "Start a new project folder that Git can track.",
|
|
373
|
+
usage: "spwn init",
|
|
374
|
+
handler: ->(args) { git("init", *args) }
|
|
375
|
+
},
|
|
376
|
+
|
|
377
|
+
save: {
|
|
378
|
+
name: "save",
|
|
379
|
+
help: "Take a snapshot of your work. First pick which files to include, then give your snapshot a note.",
|
|
380
|
+
usage: "spwn save <files...> -m 'your note' or spwn save -m 'your note'",
|
|
381
|
+
handler: ->(args) {
|
|
382
|
+
message, _rest = extract_message_from_args(args)
|
|
383
|
+
if message.nil? || message.empty?
|
|
384
|
+
error("Tell spwn what you changed with -m, like: spwn save -m 'added a score'")
|
|
385
|
+
next nil
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
if ask_yes_no("Save all the changed files in this folder?")
|
|
389
|
+
git("add", ".")
|
|
390
|
+
git("commit", "-m", message)
|
|
391
|
+
else
|
|
392
|
+
say("Save cancelled. You can pick files one by one with spwn add.")
|
|
393
|
+
1
|
|
394
|
+
end
|
|
395
|
+
}
|
|
396
|
+
},
|
|
397
|
+
|
|
398
|
+
add: {
|
|
399
|
+
name: "add",
|
|
400
|
+
help: "Tell Git which files to include in the next snapshot.",
|
|
401
|
+
usage: "spwn add <file>... or spwn add . or spwn add -A",
|
|
402
|
+
handler: ->(args) {
|
|
403
|
+
files = files_from_args(args, allow_all: true)
|
|
404
|
+
git("add", *files)
|
|
405
|
+
}
|
|
406
|
+
},
|
|
407
|
+
|
|
408
|
+
commit: {
|
|
409
|
+
name: "commit",
|
|
410
|
+
help: "Save the files you have already picked. You must include a note with -m.",
|
|
411
|
+
usage: "spwn commit -m 'your note'",
|
|
412
|
+
handler: ->(args) {
|
|
413
|
+
message, _rest = extract_message_from_args(args)
|
|
414
|
+
if message.nil? || message.empty?
|
|
415
|
+
error("Every save needs a note. Try: spwn commit -m 'made the hero jump'")
|
|
416
|
+
next nil
|
|
417
|
+
end
|
|
418
|
+
git("commit", "-m", message)
|
|
419
|
+
}
|
|
420
|
+
},
|
|
421
|
+
|
|
422
|
+
look: {
|
|
423
|
+
name: "look",
|
|
424
|
+
help: "See what is going on in your project right now.",
|
|
425
|
+
usage: "spwn look",
|
|
426
|
+
handler: ->(args) { git("status", *args) }
|
|
427
|
+
},
|
|
428
|
+
|
|
429
|
+
compare: {
|
|
430
|
+
name: "compare",
|
|
431
|
+
help: "See what changed since the last snapshot.",
|
|
432
|
+
usage: "spwn compare or spwn compare <file>",
|
|
433
|
+
handler: ->(args) { git("diff", *args) }
|
|
434
|
+
},
|
|
435
|
+
|
|
436
|
+
history: {
|
|
437
|
+
name: "history",
|
|
438
|
+
help: "Replay the story of your project, one snapshot at a time.",
|
|
439
|
+
usage: "spwn history or spwn history -n 5",
|
|
440
|
+
handler: ->(args) { git("log", *args) }
|
|
441
|
+
},
|
|
442
|
+
|
|
443
|
+
hop: {
|
|
444
|
+
name: "hop",
|
|
445
|
+
help: "Jump to another universe (branch) or bring back a file from another snapshot.",
|
|
446
|
+
usage: "spwn hop <branch> or spwn hop -- <branch> <file>",
|
|
447
|
+
handler: ->(args) {
|
|
448
|
+
if args.empty?
|
|
449
|
+
error("Where should we hop? Try a branch name, or use spwn hop -- <branch> <file> to restore a file.")
|
|
450
|
+
next nil
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
if args[0] == "--"
|
|
454
|
+
rest = args[1..]
|
|
455
|
+
if rest.nil? || rest.empty?
|
|
456
|
+
error("What should we restore? Try: spwn hop -- main my_level.rb")
|
|
457
|
+
next nil
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
if rest.length == 1
|
|
461
|
+
# Only one thing after --: could be a branch or a file.
|
|
462
|
+
# If it is a file that exists on the current HEAD, restore it from here.
|
|
463
|
+
# Otherwise ask for the source branch explicitly.
|
|
464
|
+
candidate = rest[0]
|
|
465
|
+
if file_on_head?(candidate)
|
|
466
|
+
next git("restore", "--", candidate)
|
|
467
|
+
else
|
|
468
|
+
error("I cannot tell which universe to pull #{candidate} from. Try: spwn hop -- <branch> #{candidate}")
|
|
469
|
+
next nil
|
|
470
|
+
end
|
|
471
|
+
else
|
|
472
|
+
# branch followed by one or more files
|
|
473
|
+
source_branch = rest[0]
|
|
474
|
+
files = rest[1..]
|
|
475
|
+
if files.nil? || files.empty?
|
|
476
|
+
error("Which file should we bring back from #{source_branch}? Try: spwn hop -- #{source_branch} my_level.rb")
|
|
477
|
+
next nil
|
|
478
|
+
end
|
|
479
|
+
next git("restore", "--source", source_branch, "--", *files)
|
|
480
|
+
end
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
git("switch", *args)
|
|
484
|
+
}
|
|
485
|
+
},
|
|
486
|
+
|
|
487
|
+
upload: {
|
|
488
|
+
name: "upload",
|
|
489
|
+
help: "Send your snapshots to the shared project space.",
|
|
490
|
+
usage: "spwn upload or spwn upload <branch>",
|
|
491
|
+
handler: ->(args) { git("push", *args) }
|
|
492
|
+
},
|
|
493
|
+
|
|
494
|
+
download: {
|
|
495
|
+
name: "download",
|
|
496
|
+
help: "Fetch new snapshots from the shared project space and combine them with yours.",
|
|
497
|
+
usage: "spwn download or spwn download <remote> <branch>",
|
|
498
|
+
handler: ->(args) { git("pull", *args) }
|
|
499
|
+
},
|
|
500
|
+
|
|
501
|
+
sync: {
|
|
502
|
+
name: "sync",
|
|
503
|
+
help: "Copy a lesson folder into your game folder. The first time a lesson touches a file that already exists in your game folder, spwn asks before replacing it. After you accept a file, later lessons upgrade that same file automatically, so asset updates get easier as you go.",
|
|
504
|
+
usage: "spwn sync <lesson-folder> --into <game-folder> or spwn sync <lesson-folder> --into <game-folder> --force",
|
|
505
|
+
handler: ->(args) { require_relative "synchronizer"; Spawnpoint::Synchronizer.new.run(args) }
|
|
506
|
+
},
|
|
507
|
+
|
|
508
|
+
rollback: {
|
|
509
|
+
name: "rollback",
|
|
510
|
+
help: "Undo the most recent lesson sync.",
|
|
511
|
+
usage: "spwn rollback --into <game-folder>",
|
|
512
|
+
handler: ->(args) { require_relative "synchronizer"; Spawnpoint::Synchronizer.new.rollback(args) }
|
|
513
|
+
}
|
|
514
|
+
}.freeze
|
|
515
|
+
|
|
516
|
+
SUBCOMMANDS = COMMANDS.values.freeze
|
|
517
|
+
|
|
518
|
+
# ---------------------------------------------------------------------
|
|
519
|
+
# Help text
|
|
520
|
+
# ---------------------------------------------------------------------
|
|
521
|
+
|
|
522
|
+
def print_help
|
|
523
|
+
say("spwn v#{VERSION}")
|
|
524
|
+
say("A friendly face for Git, made for learning game programming.")
|
|
525
|
+
say("")
|
|
526
|
+
say("Usage:")
|
|
527
|
+
say(" spwn <command> [options]")
|
|
528
|
+
say("")
|
|
529
|
+
say("Commands:")
|
|
530
|
+
SUBCOMMANDS.sort_by { |c| c[:name] }.each do |cmd|
|
|
531
|
+
say(" #{cmd[:name].to_s.ljust(12)} #{cmd[:help]}")
|
|
532
|
+
end
|
|
533
|
+
say("")
|
|
534
|
+
say("Tips:")
|
|
535
|
+
say(" - Run spwn <command> --help to see Git's own help for that command.")
|
|
536
|
+
say(" - You can still use git directly when you are ready.")
|
|
537
|
+
say(" - spwn save is a shortcut for adding changed files and committing them together.")
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
def print_command_help(command_name)
|
|
541
|
+
cmd = COMMANDS[command_name]
|
|
542
|
+
if cmd.nil?
|
|
543
|
+
error("I do not know that command. Run spwn --help to see the list.")
|
|
544
|
+
return
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
say("spwn #{cmd[:name]}")
|
|
548
|
+
say("")
|
|
549
|
+
say(cmd[:help])
|
|
550
|
+
say("")
|
|
551
|
+
say("Usage:")
|
|
552
|
+
say(" #{cmd[:usage]}")
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
# ---------------------------------------------------------------------
|
|
556
|
+
# Dispatch
|
|
557
|
+
# ---------------------------------------------------------------------
|
|
558
|
+
|
|
559
|
+
# Runs the CLI and returns an Integer exit code.
|
|
560
|
+
#
|
|
561
|
+
# Handlers return an Integer exit code, or true/false from `system`.
|
|
562
|
+
# Anything truthy-but-not-an-Integer and `true` map to 0; false and nil
|
|
563
|
+
# map to 1, so misuse and Git failures exit non-zero.
|
|
564
|
+
def run(argv)
|
|
565
|
+
command_name = argv[0]
|
|
566
|
+
|
|
567
|
+
if command_name.nil? || command_name == "--help" || command_name == "-h"
|
|
568
|
+
print_help
|
|
569
|
+
return 0
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
if command_name == "--version" || command_name == "-v"
|
|
573
|
+
say("spwn v#{VERSION}")
|
|
574
|
+
return 0
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
command_key = command_name.to_sym
|
|
578
|
+
cmd = COMMANDS[command_key]
|
|
579
|
+
|
|
580
|
+
unless cmd
|
|
581
|
+
error("I do not know that command: #{command_name}")
|
|
582
|
+
say("Run spwn --help to see the commands I do know.")
|
|
583
|
+
return 1
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
command_args = argv[1..] || []
|
|
587
|
+
|
|
588
|
+
if command_args_include_help?(command_args)
|
|
589
|
+
print_command_help(command_key)
|
|
590
|
+
if git_command_exists?(command_key.to_s)
|
|
591
|
+
git(command_key.to_s, *command_args)
|
|
592
|
+
else
|
|
593
|
+
say("")
|
|
594
|
+
say("Git does not have a #{command_key} command, so there is no extra help to show beyond this.")
|
|
595
|
+
end
|
|
596
|
+
return 0
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
normalize_exit(cmd[:handler].call(command_args))
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
def normalize_exit(result)
|
|
603
|
+
case result
|
|
604
|
+
when Integer then result
|
|
605
|
+
when true then 0
|
|
606
|
+
else 1
|
|
607
|
+
end
|
|
608
|
+
end
|
|
609
|
+
end
|
|
610
|
+
end
|
|
611
|
+
```
|
|
612
|
+
|
|
613
|
+
Note for the implementer: inside `COMMANDS` lambdas, `return` is replaced by `next` (a lambda `return` exits the defining scope; `next` returns from the lambda). Handlers that fail now `next nil`, which normalizes to exit code 1.
|
|
614
|
+
|
|
615
|
+
`lib/spawnpoint.rb`:
|
|
616
|
+
|
|
617
|
+
```ruby
|
|
618
|
+
# frozen_string_literal: true
|
|
619
|
+
|
|
620
|
+
require_relative "spawnpoint/version"
|
|
621
|
+
require_relative "spawnpoint/cli"
|
|
622
|
+
```
|
|
623
|
+
|
|
624
|
+
`exe/spwn`:
|
|
625
|
+
|
|
626
|
+
```ruby
|
|
627
|
+
#!/usr/bin/env ruby
|
|
628
|
+
# frozen_string_literal: true
|
|
629
|
+
|
|
630
|
+
require "spawnpoint/cli"
|
|
631
|
+
|
|
632
|
+
exit Spawnpoint::CLI.run(ARGV)
|
|
633
|
+
```
|
|
634
|
+
|
|
635
|
+
Make it executable:
|
|
636
|
+
|
|
637
|
+
```bash
|
|
638
|
+
chmod +x exe/spwn
|
|
639
|
+
```
|
|
640
|
+
|
|
641
|
+
- [ ] **Step 4: Run tests to verify they pass**
|
|
642
|
+
|
|
643
|
+
Run: `rake`
|
|
644
|
+
Expected: PASS — version + CLI tests green (9 runs, 0 failures)
|
|
645
|
+
|
|
646
|
+
- [ ] **Step 5: Smoke-test the executable from the repo**
|
|
647
|
+
|
|
648
|
+
Run: `ruby -Ilib exe/spwn --version`
|
|
649
|
+
Expected: prints `spwn v0.2.0`
|
|
650
|
+
|
|
651
|
+
Run: `ruby -Ilib exe/spwn teleport; echo "exit=$?"`
|
|
652
|
+
Expected: prints the unknown-command message and `exit=1`
|
|
653
|
+
|
|
654
|
+
Run: `ruby -Ilib exe/spwn --help | head -3`
|
|
655
|
+
Expected: help text starting with `spwn v0.2.0`
|
|
656
|
+
|
|
657
|
+
- [ ] **Step 6: Commit**
|
|
658
|
+
|
|
659
|
+
```bash
|
|
660
|
+
git add lib/spawnpoint.rb lib/spawnpoint/cli.rb exe/spwn test/test_cli.rb
|
|
661
|
+
git commit -m "move CLI into Spawnpoint::CLI with exit-code propagation"
|
|
662
|
+
```
|
|
663
|
+
|
|
664
|
+
---
|
|
665
|
+
|
|
666
|
+
### Task 3: Synchronizer with marker-merge fix
|
|
667
|
+
|
|
668
|
+
**Files:**
|
|
669
|
+
- Create: `lib/spawnpoint/synchronizer.rb`
|
|
670
|
+
- Create: `test/test_synchronizer.rb`
|
|
671
|
+
- Reference (do not modify): `bin/__sync__.rb` — the code moves from here
|
|
672
|
+
|
|
673
|
+
**Interfaces:**
|
|
674
|
+
- Consumes: nothing from other tasks (invoked lazily by `Spawnpoint::CLI`'s sync/rollback handlers from Task 2).
|
|
675
|
+
- Produces: `Spawnpoint::Synchronizer.new.run(argv)` → Integer (0 success, 1 usage error); `Spawnpoint::Synchronizer.new.rollback(argv)` → Integer. Side effects: copies files, writes `.spwn_synced_paths` marker and `.spwn_sync_backup/` in the target folder.
|
|
676
|
+
|
|
677
|
+
**Fixes relative to `bin/__sync__.rb`:**
|
|
678
|
+
1. Class renamed `Spwn::Syncronizer` → `Spawnpoint::Synchronizer`.
|
|
679
|
+
2. Marker is written as the union `known | new_known`, so paths accepted in earlier syncs are never forgotten.
|
|
680
|
+
3. Dead branch removed: when `dest.exist?`, the file is necessarily in `already_existed` (the target was scanned first), so the final `else` that copied silently is unreachable.
|
|
681
|
+
4. No-op guards `next if child == source` / `next if child == target` removed (`child` is always a file after the `child.file?` check; `source`/`target` are directories).
|
|
682
|
+
|
|
683
|
+
- [ ] **Step 1: Write the failing tests**
|
|
684
|
+
|
|
685
|
+
`test/test_synchronizer.rb`:
|
|
686
|
+
|
|
687
|
+
```ruby
|
|
688
|
+
require "test_helper"
|
|
689
|
+
require "tmpdir"
|
|
690
|
+
require "fileutils"
|
|
691
|
+
require "spawnpoint/synchronizer"
|
|
692
|
+
|
|
693
|
+
class TestSynchronizer < Minitest::Test
|
|
694
|
+
def setup
|
|
695
|
+
@dir = Dir.mktmpdir
|
|
696
|
+
@lesson = File.join(@dir, "lesson")
|
|
697
|
+
@game = File.join(@dir, "game")
|
|
698
|
+
FileUtils.mkdir_p(@lesson)
|
|
699
|
+
FileUtils.mkdir_p(@game)
|
|
700
|
+
end
|
|
701
|
+
|
|
702
|
+
def teardown
|
|
703
|
+
FileUtils.remove_entry(@dir)
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
def write(folder, name, content)
|
|
707
|
+
File.write(File.join(folder, name), content)
|
|
708
|
+
end
|
|
709
|
+
|
|
710
|
+
def sync(*extra)
|
|
711
|
+
Spawnpoint::Synchronizer.new.run([@lesson, "--into", @game, *extra])
|
|
712
|
+
end
|
|
713
|
+
|
|
714
|
+
def marker_paths
|
|
715
|
+
marker = File.join(@game, ".spwn_synced_paths")
|
|
716
|
+
File.exist?(marker) ? File.readlines(marker).map(&:strip) : []
|
|
717
|
+
end
|
|
718
|
+
|
|
719
|
+
def test_run_without_arguments_exits_one
|
|
720
|
+
capture_io { @status = Spawnpoint::Synchronizer.new.run([]) }
|
|
721
|
+
assert_equal 1, @status
|
|
722
|
+
end
|
|
723
|
+
|
|
724
|
+
def test_run_without_into_exits_one
|
|
725
|
+
capture_io { @status = Spawnpoint::Synchronizer.new.run([@lesson]) }
|
|
726
|
+
assert_equal 1, @status
|
|
727
|
+
end
|
|
728
|
+
|
|
729
|
+
def test_run_with_missing_source_exits_one
|
|
730
|
+
capture_io { @status = Spawnpoint::Synchronizer.new.run(["nope", "--into", @game]) }
|
|
731
|
+
assert_equal 1, @status
|
|
732
|
+
end
|
|
733
|
+
|
|
734
|
+
def test_copies_new_files
|
|
735
|
+
write(@lesson, "main.rb", "puts :hi")
|
|
736
|
+
capture_io { @status = sync }
|
|
737
|
+
assert_equal 0, @status
|
|
738
|
+
assert_equal "puts :hi", File.read(File.join(@game, "main.rb"))
|
|
739
|
+
assert_includes marker_paths, "main.rb"
|
|
740
|
+
end
|
|
741
|
+
|
|
742
|
+
def test_force_replaces_existing_file
|
|
743
|
+
write(@game, "main.rb", "old")
|
|
744
|
+
write(@lesson, "main.rb", "new")
|
|
745
|
+
capture_io { @status = sync("--force") }
|
|
746
|
+
assert_equal 0, @status
|
|
747
|
+
assert_equal "new", File.read(File.join(@game, "main.rb"))
|
|
748
|
+
end
|
|
749
|
+
|
|
750
|
+
def test_rollback_restores_replaced_and_removes_created_files
|
|
751
|
+
write(@game, "main.rb", "old")
|
|
752
|
+
write(@lesson, "main.rb", "new")
|
|
753
|
+
write(@lesson, "enemy.rb", "enemy code")
|
|
754
|
+
capture_io { sync("--force") }
|
|
755
|
+
assert_equal "new", File.read(File.join(@game, "main.rb"))
|
|
756
|
+
|
|
757
|
+
capture_io { @status = Spawnpoint::Synchronizer.new.rollback(["--into", @game]) }
|
|
758
|
+
assert_equal 0, @status
|
|
759
|
+
assert_equal "old", File.read(File.join(@game, "main.rb"))
|
|
760
|
+
refute File.exist?(File.join(@game, "enemy.rb"))
|
|
761
|
+
end
|
|
762
|
+
|
|
763
|
+
def test_rollback_without_backup_exits_one
|
|
764
|
+
capture_io { @status = Spawnpoint::Synchronizer.new.rollback(["--into", @game]) }
|
|
765
|
+
assert_equal 1, @status
|
|
766
|
+
end
|
|
767
|
+
|
|
768
|
+
def test_marker_keeps_paths_accepted_in_earlier_lessons
|
|
769
|
+
# Lesson 1 introduces main.rb.
|
|
770
|
+
write(@lesson, "main.rb", "v1")
|
|
771
|
+
capture_io { sync }
|
|
772
|
+
assert_includes marker_paths, "main.rb"
|
|
773
|
+
|
|
774
|
+
# Lesson 2 introduces only enemy.rb; main.rb must not be forgotten.
|
|
775
|
+
FileUtils.rm(File.join(@lesson, "main.rb"))
|
|
776
|
+
write(@lesson, "enemy.rb", "enemy code")
|
|
777
|
+
capture_io { sync }
|
|
778
|
+
|
|
779
|
+
assert_includes marker_paths, "main.rb"
|
|
780
|
+
assert_includes marker_paths, "enemy.rb"
|
|
781
|
+
end
|
|
782
|
+
end
|
|
783
|
+
```
|
|
784
|
+
|
|
785
|
+
- [ ] **Step 2: Run tests to verify they fail**
|
|
786
|
+
|
|
787
|
+
Run: `ruby -Itest test/test_synchronizer.rb`
|
|
788
|
+
Expected: FAIL with `cannot load such file -- spawnpoint/synchronizer` (LoadError)
|
|
789
|
+
|
|
790
|
+
- [ ] **Step 3: Implement `lib/spawnpoint/synchronizer.rb`**
|
|
791
|
+
|
|
792
|
+
`lib/spawnpoint/synchronizer.rb`:
|
|
793
|
+
|
|
794
|
+
```ruby
|
|
795
|
+
# frozen_string_literal: true
|
|
796
|
+
|
|
797
|
+
# Synchronizer implements `spwn sync`.
|
|
798
|
+
#
|
|
799
|
+
# It copies a lesson source folder into a DragonRuby game folder (usually
|
|
800
|
+
# mygame/). It is intentionally a copy tool, not a Git command: the course
|
|
801
|
+
# materials are distributed as folders, and the student's game folder is the
|
|
802
|
+
# place where DragonRuby loads them.
|
|
803
|
+
#
|
|
804
|
+
# Design notes:
|
|
805
|
+
#
|
|
806
|
+
# - Source is a directory, not a single file. Lessons are more than one file
|
|
807
|
+
# once assets enter the picture.
|
|
808
|
+
# - Existing files are never overwritten without the student saying so. This
|
|
809
|
+
# matters most for assets: a later lesson may ship a better sprite, and the
|
|
810
|
+
# student should choose whether to replace the older one.
|
|
811
|
+
# - When the source contains an assets/ folder, the sync prints a reminder that
|
|
812
|
+
# assets are part of this lesson and may change between lessons.
|
|
813
|
+
|
|
814
|
+
require "fileutils"
|
|
815
|
+
require "pathname"
|
|
816
|
+
require "set"
|
|
817
|
+
|
|
818
|
+
module Spawnpoint
|
|
819
|
+
class Synchronizer
|
|
820
|
+
BACKUP_DIR = ".spwn_sync_backup"
|
|
821
|
+
|
|
822
|
+
def run(argv)
|
|
823
|
+
lesson_folder, into_folder, force = parse_args(argv)
|
|
824
|
+
|
|
825
|
+
unless lesson_folder
|
|
826
|
+
puts "Usage:"
|
|
827
|
+
puts " spwn sync <lesson-folder> --into <game-folder>"
|
|
828
|
+
puts " spwn sync <lesson-folder> --into <game-folder> --force"
|
|
829
|
+
puts ""
|
|
830
|
+
puts "Example:"
|
|
831
|
+
puts " spwn sync 04-collectibles/starter --into ~/DragonRuby/mygame"
|
|
832
|
+
puts ""
|
|
833
|
+
puts "The first time a lesson copies a file that already exists in your"
|
|
834
|
+
puts "game folder, spwn asks before replacing it. After you accept a"
|
|
835
|
+
puts "file, later lessons replace that same file automatically."
|
|
836
|
+
return 1
|
|
837
|
+
end
|
|
838
|
+
|
|
839
|
+
unless into_folder
|
|
840
|
+
puts "Oops: tell spwn where to copy the lesson with --into."
|
|
841
|
+
puts "Example: spwn sync 04-collectibles/starter --into ~/DragonRuby/mygame"
|
|
842
|
+
return 1
|
|
843
|
+
end
|
|
844
|
+
|
|
845
|
+
source = Pathname.new(lesson_folder).expand_path
|
|
846
|
+
target = Pathname.new(into_folder).expand_path
|
|
847
|
+
|
|
848
|
+
unless source.directory?
|
|
849
|
+
puts "Oops: #{source} is not a folder."
|
|
850
|
+
return 1
|
|
851
|
+
end
|
|
852
|
+
|
|
853
|
+
unless target.directory?
|
|
854
|
+
puts "Oops: #{target} is not a folder yet."
|
|
855
|
+
return 1
|
|
856
|
+
end
|
|
857
|
+
|
|
858
|
+
has_assets = source.join("assets").directory?
|
|
859
|
+
|
|
860
|
+
if has_assets
|
|
861
|
+
puts "This lesson includes an assets/ folder."
|
|
862
|
+
puts "Assets may look different from the previous lesson. "
|
|
863
|
+
puts "If you already have sprites from an older lesson, you will be asked before each one is replaced."
|
|
864
|
+
puts ""
|
|
865
|
+
end
|
|
866
|
+
|
|
867
|
+
marker = target.join(".spwn_synced_paths")
|
|
868
|
+
backup = target.join(BACKUP_DIR)
|
|
869
|
+
FileUtils.rm_rf(backup)
|
|
870
|
+
backup.join("files").mkpath
|
|
871
|
+
backup.join("created_paths").write("")
|
|
872
|
+
if marker.file?
|
|
873
|
+
FileUtils.cp(marker, backup.join("marker"))
|
|
874
|
+
else
|
|
875
|
+
backup.join("no_marker").write("")
|
|
876
|
+
end
|
|
877
|
+
|
|
878
|
+
known = if marker.file?
|
|
879
|
+
marker.readlines.map(&:strip).reject(&:empty?).to_set
|
|
880
|
+
else
|
|
881
|
+
Set.new
|
|
882
|
+
end
|
|
883
|
+
|
|
884
|
+
already_existed = Set.new
|
|
885
|
+
target.find.each do |child|
|
|
886
|
+
next unless child.file?
|
|
887
|
+
already_existed << child.relative_path_from(target).to_s
|
|
888
|
+
end
|
|
889
|
+
|
|
890
|
+
copied = 0
|
|
891
|
+
upgraded = 0
|
|
892
|
+
skipped = 0
|
|
893
|
+
new_known = Set.new
|
|
894
|
+
|
|
895
|
+
source.find.to_a.each do |child|
|
|
896
|
+
next unless child.file?
|
|
897
|
+
|
|
898
|
+
rel = child.relative_path_from(source)
|
|
899
|
+
rel_s = rel.to_s
|
|
900
|
+
dest = target.join(rel)
|
|
901
|
+
|
|
902
|
+
if dest.exist?
|
|
903
|
+
if force || known.include?(rel_s)
|
|
904
|
+
backup_existing(backup, dest, rel_s)
|
|
905
|
+
copy_file(child, dest)
|
|
906
|
+
upgraded += 1
|
|
907
|
+
new_known << rel_s
|
|
908
|
+
elsif already_existed.include?(rel_s)
|
|
909
|
+
if agree?("Copy #{rel} from this lesson?")
|
|
910
|
+
backup_existing(backup, dest, rel_s)
|
|
911
|
+
copy_file(child, dest)
|
|
912
|
+
upgraded += 1
|
|
913
|
+
new_known << rel_s
|
|
914
|
+
else
|
|
915
|
+
skipped += 1
|
|
916
|
+
end
|
|
917
|
+
end
|
|
918
|
+
else
|
|
919
|
+
copy_file(child, dest)
|
|
920
|
+
File.open(backup.join("created_paths"), "a") { |fh| fh.puts(rel_s) }
|
|
921
|
+
copied += 1
|
|
922
|
+
new_known << rel_s
|
|
923
|
+
end
|
|
924
|
+
end
|
|
925
|
+
|
|
926
|
+
merged = known | new_known
|
|
927
|
+
if merged.any?
|
|
928
|
+
marker.open("w") do |fh|
|
|
929
|
+
merged.to_a.sort.each do |name|
|
|
930
|
+
fh.puts(name)
|
|
931
|
+
end
|
|
932
|
+
end
|
|
933
|
+
end
|
|
934
|
+
|
|
935
|
+
puts ""
|
|
936
|
+
puts "Done."
|
|
937
|
+
puts "New: #{copied}"
|
|
938
|
+
puts "Updated: #{upgraded}"
|
|
939
|
+
puts "Skipped: #{skipped}"
|
|
940
|
+
|
|
941
|
+
0
|
|
942
|
+
end
|
|
943
|
+
|
|
944
|
+
def rollback(argv)
|
|
945
|
+
target = parse_rollback_args(argv)
|
|
946
|
+
unless target
|
|
947
|
+
puts "Usage: spwn rollback --into <game-folder>"
|
|
948
|
+
return 1
|
|
949
|
+
end
|
|
950
|
+
|
|
951
|
+
target = Pathname.new(target).expand_path
|
|
952
|
+
backup = target.join(BACKUP_DIR)
|
|
953
|
+
unless backup.directory?
|
|
954
|
+
puts "Oops: there is no lesson sync to roll back in #{target}."
|
|
955
|
+
return 1
|
|
956
|
+
end
|
|
957
|
+
|
|
958
|
+
backup.join("created_paths").readlines.each do |line|
|
|
959
|
+
target.join(line.strip).delete if !line.strip.empty? && target.join(line.strip).file?
|
|
960
|
+
end
|
|
961
|
+
backup.join("files").find do |saved|
|
|
962
|
+
next if saved.directory? || saved == backup.join("files")
|
|
963
|
+
rel = saved.relative_path_from(backup.join("files"))
|
|
964
|
+
dest = target.join(rel)
|
|
965
|
+
FileUtils.mkdir_p(dest.parent)
|
|
966
|
+
FileUtils.cp(saved, dest)
|
|
967
|
+
end
|
|
968
|
+
|
|
969
|
+
marker = target.join(".spwn_synced_paths")
|
|
970
|
+
if backup.join("marker").file?
|
|
971
|
+
FileUtils.cp(backup.join("marker"), marker)
|
|
972
|
+
else
|
|
973
|
+
marker.delete if marker.file?
|
|
974
|
+
end
|
|
975
|
+
|
|
976
|
+
FileUtils.rm_rf(backup)
|
|
977
|
+
puts "Rolled back the most recent lesson sync."
|
|
978
|
+
0
|
|
979
|
+
end
|
|
980
|
+
|
|
981
|
+
private
|
|
982
|
+
|
|
983
|
+
def parse_args(argv)
|
|
984
|
+
lesson_folder = nil
|
|
985
|
+
into_folder = nil
|
|
986
|
+
force = false
|
|
987
|
+
|
|
988
|
+
i = 0
|
|
989
|
+
while i < argv.length
|
|
990
|
+
arg = argv[i]
|
|
991
|
+
case arg
|
|
992
|
+
when "--into"
|
|
993
|
+
i += 1
|
|
994
|
+
into_folder = argv[i]
|
|
995
|
+
when "--force", "-f"
|
|
996
|
+
force = true
|
|
997
|
+
when /\A-/
|
|
998
|
+
puts "Oops: I do not understand #{arg}."
|
|
999
|
+
return [nil, nil, false]
|
|
1000
|
+
else
|
|
1001
|
+
lesson_folder ||= arg
|
|
1002
|
+
end
|
|
1003
|
+
i += 1
|
|
1004
|
+
end
|
|
1005
|
+
|
|
1006
|
+
[lesson_folder, into_folder, force]
|
|
1007
|
+
end
|
|
1008
|
+
|
|
1009
|
+
def parse_rollback_args(argv)
|
|
1010
|
+
i = argv.index("--into")
|
|
1011
|
+
i && argv[i + 1]
|
|
1012
|
+
end
|
|
1013
|
+
|
|
1014
|
+
def backup_existing(backup, dest, rel)
|
|
1015
|
+
saved = backup.join("files", rel)
|
|
1016
|
+
FileUtils.mkdir_p(saved.parent)
|
|
1017
|
+
FileUtils.cp(dest, saved)
|
|
1018
|
+
end
|
|
1019
|
+
|
|
1020
|
+
def copy_file(source, dest)
|
|
1021
|
+
FileUtils.mkdir_p(dest.parent)
|
|
1022
|
+
FileUtils.cp(source, dest)
|
|
1023
|
+
end
|
|
1024
|
+
|
|
1025
|
+
def agree?(question)
|
|
1026
|
+
loop do
|
|
1027
|
+
print "#{question} (y/n): "
|
|
1028
|
+
answer = STDIN.gets
|
|
1029
|
+
return false if answer.nil?
|
|
1030
|
+
|
|
1031
|
+
answer = answer.strip.downcase
|
|
1032
|
+
return true if answer == "y"
|
|
1033
|
+
return false if answer == "n"
|
|
1034
|
+
puts "Please type y or n."
|
|
1035
|
+
end
|
|
1036
|
+
end
|
|
1037
|
+
end
|
|
1038
|
+
end
|
|
1039
|
+
```
|
|
1040
|
+
|
|
1041
|
+
- [ ] **Step 4: Run tests to verify they pass**
|
|
1042
|
+
|
|
1043
|
+
Run: `rake`
|
|
1044
|
+
Expected: PASS — all tests green, including `test_marker_keeps_paths_accepted_in_earlier_lessons` (the regression test for the marker-merge bug)
|
|
1045
|
+
|
|
1046
|
+
- [ ] **Step 5: Smoke-test sync through the CLI**
|
|
1047
|
+
|
|
1048
|
+
```bash
|
|
1049
|
+
mkdir -p /tmp/spwn-lesson /tmp/spwn-game
|
|
1050
|
+
echo "puts :hi" > /tmp/spwn-lesson/main.rb
|
|
1051
|
+
ruby -Ilib exe/spwn sync /tmp/spwn-lesson --into /tmp/spwn-game
|
|
1052
|
+
cat /tmp/spwn-game/main.rb
|
|
1053
|
+
ruby -Ilib exe/spwn rollback --into /tmp/spwn-game
|
|
1054
|
+
ls /tmp/spwn-game
|
|
1055
|
+
ruby -Ilib exe/spwn sync; echo "exit=$?"
|
|
1056
|
+
rm -rf /tmp/spwn-lesson /tmp/spwn-game
|
|
1057
|
+
```
|
|
1058
|
+
|
|
1059
|
+
Expected: file copied, then removed by rollback; bare `spwn sync` prints usage and `exit=1`.
|
|
1060
|
+
|
|
1061
|
+
- [ ] **Step 6: Commit**
|
|
1062
|
+
|
|
1063
|
+
```bash
|
|
1064
|
+
git add lib/spawnpoint/synchronizer.rb test/test_synchronizer.rb
|
|
1065
|
+
git commit -m "add Spawnpoint::Synchronizer with marker-merge fix"
|
|
1066
|
+
```
|
|
1067
|
+
|
|
1068
|
+
---
|
|
1069
|
+
|
|
1070
|
+
### Task 4: Delete `bin/`, rewrite README, final gem verification
|
|
1071
|
+
|
|
1072
|
+
**Files:**
|
|
1073
|
+
- Delete: `bin/spwn`, `bin/__sync__.rb`
|
|
1074
|
+
- Modify: `README.md`
|
|
1075
|
+
|
|
1076
|
+
**Interfaces:**
|
|
1077
|
+
- Consumes: everything from Tasks 1–3.
|
|
1078
|
+
- Produces: the final repo state; a locally installable `spawnpoint-0.2.0.gem`.
|
|
1079
|
+
|
|
1080
|
+
- [ ] **Step 1: Delete the old script files**
|
|
1081
|
+
|
|
1082
|
+
```bash
|
|
1083
|
+
git rm bin/spwn bin/__sync__.rb
|
|
1084
|
+
```
|
|
1085
|
+
|
|
1086
|
+
- [ ] **Step 2: Rewrite the README install sections**
|
|
1087
|
+
|
|
1088
|
+
Make these edits to `README.md`:
|
|
1089
|
+
|
|
1090
|
+
1. Line 32, replace:
|
|
1091
|
+
|
|
1092
|
+
```markdown
|
|
1093
|
+
Once Ruby and Git are installed, `spwn` itself is just one script.
|
|
1094
|
+
```
|
|
1095
|
+
|
|
1096
|
+
with:
|
|
1097
|
+
|
|
1098
|
+
```markdown
|
|
1099
|
+
Once Ruby and Git are installed, `spwn` itself is one `gem install` away.
|
|
1100
|
+
```
|
|
1101
|
+
|
|
1102
|
+
2. Line 40, replace:
|
|
1103
|
+
|
|
1104
|
+
```markdown
|
|
1105
|
+
- Easy to extend. The mapping between `spwn` commands and Git invocations lives inside
|
|
1106
|
+
`bin/spwn` so students and instructors can add or change commands without juggling extra files.
|
|
1107
|
+
```
|
|
1108
|
+
|
|
1109
|
+
with:
|
|
1110
|
+
|
|
1111
|
+
```markdown
|
|
1112
|
+
- Easy to extend. The mapping between `spwn` commands and Git invocations lives inside
|
|
1113
|
+
`lib/spawnpoint/cli.rb` so students and instructors can add or change commands in one place.
|
|
1114
|
+
```
|
|
1115
|
+
|
|
1116
|
+
3. Replace the whole "Quick start" section (lines 43–56) with:
|
|
1117
|
+
|
|
1118
|
+
```markdown
|
|
1119
|
+
## Quick start
|
|
1120
|
+
|
|
1121
|
+
Install the gem and run `spwn`:
|
|
1122
|
+
|
|
1123
|
+
```bash
|
|
1124
|
+
gem install spawnpoint
|
|
1125
|
+
spwn --help
|
|
1126
|
+
```
|
|
1127
|
+
|
|
1128
|
+
To run the latest code from this repository instead:
|
|
1129
|
+
|
|
1130
|
+
```bash
|
|
1131
|
+
ruby -Ilib exe/spwn --help
|
|
1132
|
+
```
|
|
1133
|
+
```
|
|
1134
|
+
|
|
1135
|
+
4. Replace the whole "Installing for a student" section (lines 58–101, including the macOS, Windows, and Compressed archive subsections) with:
|
|
1136
|
+
|
|
1137
|
+
```markdown
|
|
1138
|
+
## Installing for a student
|
|
1139
|
+
|
|
1140
|
+
`spwn` is published as the `spawnpoint` gem, so installation is the same on macOS
|
|
1141
|
+
and Windows once Ruby is installed:
|
|
1142
|
+
|
|
1143
|
+
```bash
|
|
1144
|
+
gem install spawnpoint
|
|
1145
|
+
```
|
|
1146
|
+
|
|
1147
|
+
This puts the `spwn` command on the PATH. Updating later is `gem update spawnpoint`.
|
|
1148
|
+
|
|
1149
|
+
If a machine cannot reach rubygems.org, build the gem from this repository and
|
|
1150
|
+
install the file directly:
|
|
1151
|
+
|
|
1152
|
+
```bash
|
|
1153
|
+
gem build spawnpoint.gemspec
|
|
1154
|
+
gem install --local ./spawnpoint-0.2.0.gem
|
|
1155
|
+
```
|
|
1156
|
+
```
|
|
1157
|
+
|
|
1158
|
+
5. Line 139, replace:
|
|
1159
|
+
|
|
1160
|
+
```markdown
|
|
1161
|
+
Run `ruby bin/spwn --help` for the current list.
|
|
1162
|
+
```
|
|
1163
|
+
|
|
1164
|
+
with:
|
|
1165
|
+
|
|
1166
|
+
```markdown
|
|
1167
|
+
Run `spwn --help` for the current list.
|
|
1168
|
+
```
|
|
1169
|
+
|
|
1170
|
+
6. Line 163, replace:
|
|
1171
|
+
|
|
1172
|
+
```markdown
|
|
1173
|
+
Open `bin/spwn` and look for the command mapping table near the top of the script.
|
|
1174
|
+
```
|
|
1175
|
+
|
|
1176
|
+
with:
|
|
1177
|
+
|
|
1178
|
+
```markdown
|
|
1179
|
+
Open `lib/spawnpoint/cli.rb` and look for the command mapping table near the top of the file.
|
|
1180
|
+
```
|
|
1181
|
+
|
|
1182
|
+
- [ ] **Step 3: Run the full test suite**
|
|
1183
|
+
|
|
1184
|
+
Run: `rake`
|
|
1185
|
+
Expected: PASS — all tests green
|
|
1186
|
+
|
|
1187
|
+
- [ ] **Step 4: Build and install the gem locally**
|
|
1188
|
+
|
|
1189
|
+
```bash
|
|
1190
|
+
gem build spawnpoint.gemspec
|
|
1191
|
+
GEM_HOME=/tmp/spwn-gem-test gem install --local ./spawnpoint-0.2.0.gem
|
|
1192
|
+
GEM_HOME=/tmp/spwn-gem-test GEM_PATH=/tmp/spwn-gem-test /tmp/spwn-gem-test/bin/spwn --version
|
|
1193
|
+
GEM_HOME=/tmp/spwn-gem-test GEM_PATH=/tmp/spwn-gem-test /tmp/spwn-gem-test/bin/spwn --help | head -3
|
|
1194
|
+
```
|
|
1195
|
+
|
|
1196
|
+
Expected: build succeeds; installed `spwn` prints `spwn v0.2.0` and the help text. Then clean up:
|
|
1197
|
+
|
|
1198
|
+
```bash
|
|
1199
|
+
rm -rf /tmp/spwn-gem-test spawnpoint-0.2.0.gem
|
|
1200
|
+
```
|
|
1201
|
+
|
|
1202
|
+
- [ ] **Step 5: Verify the gem contents**
|
|
1203
|
+
|
|
1204
|
+
```bash
|
|
1205
|
+
gem build spawnpoint.gemspec
|
|
1206
|
+
gem spec spawnpoint-0.2.0.gem files
|
|
1207
|
+
```
|
|
1208
|
+
|
|
1209
|
+
Expected: the file list includes `exe/spwn`, `lib/spawnpoint.rb`, `lib/spawnpoint/version.rb`, `lib/spawnpoint/cli.rb`, `lib/spawnpoint/synchronizer.rb`, `README.md`, `LICENSE` — and does NOT include `bin/spwn`, `bin/__sync__.rb`, or the untracked pipeline files. Clean up: `rm spawnpoint-0.2.0.gem`.
|
|
1210
|
+
|
|
1211
|
+
- [ ] **Step 6: Commit**
|
|
1212
|
+
|
|
1213
|
+
```bash
|
|
1214
|
+
git add -A bin README.md
|
|
1215
|
+
git commit -m "replace bin/ scripts with the spawnpoint gem layout"
|
|
1216
|
+
```
|
|
1217
|
+
|
|
1218
|
+
---
|
|
1219
|
+
|
|
1220
|
+
## Follow-up (not part of this plan — maintainer steps)
|
|
1221
|
+
|
|
1222
|
+
1. Tag `v0.2.0`, push to GitHub, create the GitHub release.
|
|
1223
|
+
2. `gem build spawnpoint.gemspec && gem push spawnpoint-0.2.0.gem` — needs the maintainer's rubygems.org credentials (MFA is required per the gemspec metadata).
|