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
data/exe/spwn
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "version"
|
|
4
|
+
|
|
5
|
+
module Spawnpoint
|
|
6
|
+
# CLI implements the spwn command-line interface.
|
|
7
|
+
#
|
|
8
|
+
# It is a thin wrapper around Git. It renames Git commands into friendlier,
|
|
9
|
+
# game-like language and delegates the actual work to Git.
|
|
10
|
+
#
|
|
11
|
+
# The mapping from "spwn commands" to Git invocations lives in the
|
|
12
|
+
# COMMANDS table below so it is easy to extend without changing the
|
|
13
|
+
# dispatch logic.
|
|
14
|
+
module CLI
|
|
15
|
+
extend self
|
|
16
|
+
|
|
17
|
+
# ---------------------------------------------------------------------
|
|
18
|
+
# Helper methods
|
|
19
|
+
# ---------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
def git(*args)
|
|
22
|
+
system("git", *args)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def say(message)
|
|
26
|
+
puts message
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def error(message)
|
|
30
|
+
say("Oops: #{message}")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def ask_yes_no(question)
|
|
34
|
+
loop do
|
|
35
|
+
print "#{question} (y/n): "
|
|
36
|
+
answer = STDIN.gets.to_s.strip.downcase
|
|
37
|
+
return true if answer == "y"
|
|
38
|
+
return false if answer == "n"
|
|
39
|
+
say("Please type y or n.")
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def extract_message_from_args(args)
|
|
44
|
+
message_parts = []
|
|
45
|
+
remaining = []
|
|
46
|
+
i = 0
|
|
47
|
+
while i < args.length
|
|
48
|
+
if args[i] == "-m" && i + 1 < args.length
|
|
49
|
+
message_parts << args[i + 1]
|
|
50
|
+
i += 2
|
|
51
|
+
elsif args[i] == "--message" && i + 1 < args.length
|
|
52
|
+
message_parts << args[i + 1]
|
|
53
|
+
i += 2
|
|
54
|
+
else
|
|
55
|
+
remaining << args[i]
|
|
56
|
+
i += 1
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
[message_parts.join(" "), remaining]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def files_from_args(args, allow_all: false)
|
|
63
|
+
if allow_all && args.include?("-A")
|
|
64
|
+
["-A"]
|
|
65
|
+
elsif args.empty? || args.include?(".")
|
|
66
|
+
["."]
|
|
67
|
+
else
|
|
68
|
+
args
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def command_args_include_help?(args)
|
|
73
|
+
args.include?("--help") || args.include?("-h")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def git_command_exists?(name)
|
|
77
|
+
# A quick probe using git's own error reporting.
|
|
78
|
+
system("git", name, "--help", out: File::NULL, err: File::NULL)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def file_on_head?(path)
|
|
82
|
+
# Returns true when the given path is tracked and present on the current HEAD.
|
|
83
|
+
# We use git ls-files so the check respects the index/HEAD rather than the
|
|
84
|
+
# working tree alone.
|
|
85
|
+
system("git", "ls-files", "--error-unmatch", path, out: File::NULL, err: File::NULL)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# ---------------------------------------------------------------------
|
|
89
|
+
# Command mapping table
|
|
90
|
+
#
|
|
91
|
+
# Each entry is a hash with:
|
|
92
|
+
# :name - the spwn subcommand students type
|
|
93
|
+
# :help - a short child-friendly description
|
|
94
|
+
# :usage - a short usage hint
|
|
95
|
+
# :handler - a callable that receives the remaining arguments
|
|
96
|
+
#
|
|
97
|
+
# Keeping this table in one place is deliberate: it is the single place
|
|
98
|
+
# to look when you want to add or change a command.
|
|
99
|
+
# ---------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
COMMANDS = {
|
|
102
|
+
init: {
|
|
103
|
+
name: "init",
|
|
104
|
+
help: "Start a new project folder that Git can track.",
|
|
105
|
+
usage: "spwn init",
|
|
106
|
+
handler: ->(args) { git("init", *args) }
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
save: {
|
|
110
|
+
name: "save",
|
|
111
|
+
help: "Take a snapshot of your work. First pick which files to include, then give your snapshot a note.",
|
|
112
|
+
usage: "spwn save <files...> -m 'your note' or spwn save -m 'your note'",
|
|
113
|
+
handler: ->(args) {
|
|
114
|
+
message, _rest = extract_message_from_args(args)
|
|
115
|
+
if message.nil? || message.empty?
|
|
116
|
+
error("Tell spwn what you changed with -m, like: spwn save -m 'added a score'")
|
|
117
|
+
next nil
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
if ask_yes_no("Save all the changed files in this folder?")
|
|
121
|
+
git("add", ".")
|
|
122
|
+
git("commit", "-m", message)
|
|
123
|
+
else
|
|
124
|
+
say("Save cancelled. You can pick files one by one with spwn add.")
|
|
125
|
+
1
|
|
126
|
+
end
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
add: {
|
|
131
|
+
name: "add",
|
|
132
|
+
help: "Tell Git which files to include in the next snapshot.",
|
|
133
|
+
usage: "spwn add <file>... or spwn add . or spwn add -A",
|
|
134
|
+
handler: ->(args) {
|
|
135
|
+
files = files_from_args(args, allow_all: true)
|
|
136
|
+
git("add", *files)
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
commit: {
|
|
141
|
+
name: "commit",
|
|
142
|
+
help: "Save the files you have already picked. You must include a note with -m.",
|
|
143
|
+
usage: "spwn commit -m 'your note'",
|
|
144
|
+
handler: ->(args) {
|
|
145
|
+
message, _rest = extract_message_from_args(args)
|
|
146
|
+
if message.nil? || message.empty?
|
|
147
|
+
error("Every save needs a note. Try: spwn commit -m 'made the hero jump'")
|
|
148
|
+
next nil
|
|
149
|
+
end
|
|
150
|
+
git("commit", "-m", message)
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
look: {
|
|
155
|
+
name: "look",
|
|
156
|
+
help: "See what is going on in your project right now.",
|
|
157
|
+
usage: "spwn look",
|
|
158
|
+
handler: ->(args) { git("status", *args) }
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
compare: {
|
|
162
|
+
name: "compare",
|
|
163
|
+
help: "See what changed since the last snapshot.",
|
|
164
|
+
usage: "spwn compare or spwn compare <file>",
|
|
165
|
+
handler: ->(args) { git("diff", *args) }
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
history: {
|
|
169
|
+
name: "history",
|
|
170
|
+
help: "Replay the story of your project, one snapshot at a time.",
|
|
171
|
+
usage: "spwn history or spwn history -n 5",
|
|
172
|
+
handler: ->(args) { git("log", *args) }
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
hop: {
|
|
176
|
+
name: "hop",
|
|
177
|
+
help: "Jump to another universe (branch) or bring back a file from another snapshot.",
|
|
178
|
+
usage: "spwn hop <branch> or spwn hop -- <branch> <file>",
|
|
179
|
+
handler: ->(args) {
|
|
180
|
+
if args.empty?
|
|
181
|
+
error("Where should we hop? Try a branch name, or use spwn hop -- <branch> <file> to restore a file.")
|
|
182
|
+
next nil
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
if args[0] == "--"
|
|
186
|
+
rest = args[1..]
|
|
187
|
+
if rest.nil? || rest.empty?
|
|
188
|
+
error("What should we restore? Try: spwn hop -- main my_level.rb")
|
|
189
|
+
next nil
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
if rest.length == 1
|
|
193
|
+
# Only one thing after --: could be a branch or a file.
|
|
194
|
+
# If it is a file that exists on the current HEAD, restore it from here.
|
|
195
|
+
# Otherwise ask for the source branch explicitly.
|
|
196
|
+
candidate = rest[0]
|
|
197
|
+
if file_on_head?(candidate)
|
|
198
|
+
next git("restore", "--", candidate)
|
|
199
|
+
else
|
|
200
|
+
error("I cannot tell which universe to pull #{candidate} from. Try: spwn hop -- <branch> #{candidate}")
|
|
201
|
+
next nil
|
|
202
|
+
end
|
|
203
|
+
else
|
|
204
|
+
# branch followed by one or more files
|
|
205
|
+
source_branch = rest[0]
|
|
206
|
+
files = rest[1..]
|
|
207
|
+
if files.nil? || files.empty?
|
|
208
|
+
error("Which file should we bring back from #{source_branch}? Try: spwn hop -- #{source_branch} my_level.rb")
|
|
209
|
+
next nil
|
|
210
|
+
end
|
|
211
|
+
next git("restore", "--source", source_branch, "--", *files)
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
git("switch", *args)
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
upload: {
|
|
220
|
+
name: "upload",
|
|
221
|
+
help: "Send your snapshots to the shared project space.",
|
|
222
|
+
usage: "spwn upload or spwn upload <branch>",
|
|
223
|
+
handler: ->(args) { git("push", *args) }
|
|
224
|
+
},
|
|
225
|
+
|
|
226
|
+
download: {
|
|
227
|
+
name: "download",
|
|
228
|
+
help: "Fetch new snapshots from the shared project space and combine them with yours.",
|
|
229
|
+
usage: "spwn download or spwn download <remote> <branch>",
|
|
230
|
+
handler: ->(args) { git("pull", *args) }
|
|
231
|
+
},
|
|
232
|
+
|
|
233
|
+
sync: {
|
|
234
|
+
name: "sync",
|
|
235
|
+
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.",
|
|
236
|
+
usage: "spwn sync <lesson-folder> --into <game-folder> or spwn sync <lesson-folder> --into <game-folder> --force",
|
|
237
|
+
handler: ->(args) { require_relative "synchronizer"; Spawnpoint::Synchronizer.new.run(args) }
|
|
238
|
+
},
|
|
239
|
+
|
|
240
|
+
rollback: {
|
|
241
|
+
name: "rollback",
|
|
242
|
+
help: "Undo the most recent lesson sync.",
|
|
243
|
+
usage: "spwn rollback --into <game-folder>",
|
|
244
|
+
handler: ->(args) { require_relative "synchronizer"; Spawnpoint::Synchronizer.new.rollback(args) }
|
|
245
|
+
}
|
|
246
|
+
}.freeze
|
|
247
|
+
|
|
248
|
+
SUBCOMMANDS = COMMANDS.values.freeze
|
|
249
|
+
|
|
250
|
+
# ---------------------------------------------------------------------
|
|
251
|
+
# Help text
|
|
252
|
+
# ---------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
def print_help
|
|
255
|
+
say("spwn v#{VERSION}")
|
|
256
|
+
say("A friendly face for Git, made for learning game programming.")
|
|
257
|
+
say("")
|
|
258
|
+
say("Usage:")
|
|
259
|
+
say(" spwn <command> [options]")
|
|
260
|
+
say("")
|
|
261
|
+
say("Commands:")
|
|
262
|
+
SUBCOMMANDS.sort_by { |c| c[:name] }.each do |cmd|
|
|
263
|
+
say(" #{cmd[:name].to_s.ljust(12)} #{cmd[:help]}")
|
|
264
|
+
end
|
|
265
|
+
say("")
|
|
266
|
+
say("Tips:")
|
|
267
|
+
say(" - Run spwn <command> --help to see Git's own help for that command.")
|
|
268
|
+
say(" - You can still use git directly when you are ready.")
|
|
269
|
+
say(" - spwn save is a shortcut for adding changed files and committing them together.")
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def print_command_help(command_name)
|
|
273
|
+
cmd = COMMANDS[command_name]
|
|
274
|
+
if cmd.nil?
|
|
275
|
+
error("I do not know that command. Run spwn --help to see the list.")
|
|
276
|
+
return
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
say("spwn #{cmd[:name]}")
|
|
280
|
+
say("")
|
|
281
|
+
say(cmd[:help])
|
|
282
|
+
say("")
|
|
283
|
+
say("Usage:")
|
|
284
|
+
say(" #{cmd[:usage]}")
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# ---------------------------------------------------------------------
|
|
288
|
+
# Dispatch
|
|
289
|
+
# ---------------------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
# Runs the CLI and returns an Integer exit code.
|
|
292
|
+
#
|
|
293
|
+
# Handlers return an Integer exit code, or true/false from `system`.
|
|
294
|
+
# Integers pass through, `true` maps to 0, and everything else (false, nil)
|
|
295
|
+
# maps to 1, so misuse and Git failures exit non-zero.
|
|
296
|
+
def run(argv)
|
|
297
|
+
command_name = argv[0]
|
|
298
|
+
|
|
299
|
+
if command_name.nil? || command_name == "--help" || command_name == "-h"
|
|
300
|
+
print_help
|
|
301
|
+
return 0
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
if command_name == "--version" || command_name == "-v"
|
|
305
|
+
say("spwn v#{VERSION}")
|
|
306
|
+
return 0
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
command_key = command_name.to_sym
|
|
310
|
+
cmd = COMMANDS[command_key]
|
|
311
|
+
|
|
312
|
+
unless cmd
|
|
313
|
+
error("I do not know that command: #{command_name}")
|
|
314
|
+
say("Run spwn --help to see the commands I do know.")
|
|
315
|
+
return 1
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
command_args = argv[1..] || []
|
|
319
|
+
|
|
320
|
+
if command_args_include_help?(command_args)
|
|
321
|
+
print_command_help(command_key)
|
|
322
|
+
if git_command_exists?(command_key.to_s)
|
|
323
|
+
git(command_key.to_s, *command_args)
|
|
324
|
+
else
|
|
325
|
+
say("")
|
|
326
|
+
say("Git does not have a #{command_key} command, so there is no extra help to show beyond this.")
|
|
327
|
+
end
|
|
328
|
+
return 0
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
normalize_exit(cmd[:handler].call(command_args))
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def normalize_exit(result)
|
|
335
|
+
case result
|
|
336
|
+
when Integer then result
|
|
337
|
+
when true then 0
|
|
338
|
+
else 1
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
end
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Synchronizer implements `spwn sync`.
|
|
4
|
+
#
|
|
5
|
+
# It copies a lesson source folder into a DragonRuby game folder (usually
|
|
6
|
+
# mygame/). It is intentionally a copy tool, not a Git command: the course
|
|
7
|
+
# materials are distributed as folders, and the student's game folder is the
|
|
8
|
+
# place where DragonRuby loads them.
|
|
9
|
+
#
|
|
10
|
+
# Design notes:
|
|
11
|
+
#
|
|
12
|
+
# - Source is a directory, not a single file. Lessons are more than one file
|
|
13
|
+
# once assets enter the picture.
|
|
14
|
+
# - Existing files are never overwritten without the student saying so. This
|
|
15
|
+
# matters most for assets: a later lesson may ship a better sprite, and the
|
|
16
|
+
# student should choose whether to replace the older one.
|
|
17
|
+
# - When the source contains an assets/ folder, the sync prints a reminder that
|
|
18
|
+
# assets are part of this lesson and may change between lessons.
|
|
19
|
+
|
|
20
|
+
require "fileutils"
|
|
21
|
+
require "pathname"
|
|
22
|
+
require "set"
|
|
23
|
+
|
|
24
|
+
module Spawnpoint
|
|
25
|
+
class Synchronizer
|
|
26
|
+
BACKUP_DIR = ".spwn_sync_backup"
|
|
27
|
+
|
|
28
|
+
def run(argv)
|
|
29
|
+
lesson_folder, into_folder, force = parse_args(argv)
|
|
30
|
+
|
|
31
|
+
unless lesson_folder
|
|
32
|
+
puts "Usage:"
|
|
33
|
+
puts " spwn sync <lesson-folder> --into <game-folder>"
|
|
34
|
+
puts " spwn sync <lesson-folder> --into <game-folder> --force"
|
|
35
|
+
puts ""
|
|
36
|
+
puts "Example:"
|
|
37
|
+
puts " spwn sync 04-collectibles/starter --into ~/DragonRuby/mygame"
|
|
38
|
+
puts ""
|
|
39
|
+
puts "The first time a lesson copies a file that already exists in your"
|
|
40
|
+
puts "game folder, spwn asks before replacing it. After you accept a"
|
|
41
|
+
puts "file, later lessons replace that same file automatically."
|
|
42
|
+
return 1
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
unless into_folder
|
|
46
|
+
puts "Oops: tell spwn where to copy the lesson with --into."
|
|
47
|
+
puts "Example: spwn sync 04-collectibles/starter --into ~/DragonRuby/mygame"
|
|
48
|
+
return 1
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
source = Pathname.new(lesson_folder).expand_path
|
|
52
|
+
target = Pathname.new(into_folder).expand_path
|
|
53
|
+
|
|
54
|
+
unless source.directory?
|
|
55
|
+
puts "Oops: #{source} is not a folder."
|
|
56
|
+
return 1
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
unless target.directory?
|
|
60
|
+
puts "Oops: #{target} is not a folder yet."
|
|
61
|
+
return 1
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
has_assets = source.join("assets").directory?
|
|
65
|
+
|
|
66
|
+
if has_assets
|
|
67
|
+
puts "This lesson includes an assets/ folder."
|
|
68
|
+
puts "Assets may look different from the previous lesson."
|
|
69
|
+
puts "If you already have sprites from an older lesson, you will be asked before each one is replaced."
|
|
70
|
+
puts ""
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
marker = target.join(".spwn_synced_paths")
|
|
74
|
+
backup = target.join(BACKUP_DIR)
|
|
75
|
+
FileUtils.rm_rf(backup)
|
|
76
|
+
backup.join("files").mkpath
|
|
77
|
+
backup.join("created_paths").write("")
|
|
78
|
+
if marker.file?
|
|
79
|
+
FileUtils.cp(marker, backup.join("marker"))
|
|
80
|
+
else
|
|
81
|
+
backup.join("no_marker").write("")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
known = if marker.file?
|
|
85
|
+
marker.readlines.map(&:strip).reject(&:empty?).to_set
|
|
86
|
+
else
|
|
87
|
+
Set.new
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
already_existed = Set.new
|
|
91
|
+
target.find.each do |child|
|
|
92
|
+
next unless child.file?
|
|
93
|
+
already_existed << child.relative_path_from(target).to_s
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
copied = 0
|
|
97
|
+
upgraded = 0
|
|
98
|
+
skipped = 0
|
|
99
|
+
new_known = Set.new
|
|
100
|
+
|
|
101
|
+
source.find.to_a.each do |child|
|
|
102
|
+
next unless child.file?
|
|
103
|
+
|
|
104
|
+
rel = child.relative_path_from(source)
|
|
105
|
+
rel_s = rel.to_s
|
|
106
|
+
dest = target.join(rel)
|
|
107
|
+
|
|
108
|
+
if dest.exist?
|
|
109
|
+
if force || known.include?(rel_s)
|
|
110
|
+
backup_existing(backup, dest, rel_s)
|
|
111
|
+
copy_file(child, dest)
|
|
112
|
+
upgraded += 1
|
|
113
|
+
new_known << rel_s
|
|
114
|
+
elsif already_existed.include?(rel_s)
|
|
115
|
+
if agree?("Copy #{rel} from this lesson?")
|
|
116
|
+
backup_existing(backup, dest, rel_s)
|
|
117
|
+
copy_file(child, dest)
|
|
118
|
+
upgraded += 1
|
|
119
|
+
new_known << rel_s
|
|
120
|
+
else
|
|
121
|
+
skipped += 1
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
else
|
|
125
|
+
copy_file(child, dest)
|
|
126
|
+
File.open(backup.join("created_paths"), "a") { |fh| fh.puts(rel_s) }
|
|
127
|
+
copied += 1
|
|
128
|
+
new_known << rel_s
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
merged = known | new_known
|
|
133
|
+
if merged.any?
|
|
134
|
+
marker.open("w") do |fh|
|
|
135
|
+
merged.to_a.sort.each do |name|
|
|
136
|
+
fh.puts(name)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
puts ""
|
|
142
|
+
puts "Done."
|
|
143
|
+
puts "New: #{copied}"
|
|
144
|
+
puts "Updated: #{upgraded}"
|
|
145
|
+
puts "Skipped: #{skipped}"
|
|
146
|
+
|
|
147
|
+
0
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def rollback(argv)
|
|
151
|
+
target = parse_rollback_args(argv)
|
|
152
|
+
unless target
|
|
153
|
+
puts "Usage: spwn rollback --into <game-folder>"
|
|
154
|
+
return 1
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
target = Pathname.new(target).expand_path
|
|
158
|
+
backup = target.join(BACKUP_DIR)
|
|
159
|
+
unless backup.directory?
|
|
160
|
+
puts "Oops: there is no lesson sync to roll back in #{target}."
|
|
161
|
+
return 1
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
backup.join("created_paths").readlines.each do |line|
|
|
165
|
+
target.join(line.strip).delete if !line.strip.empty? && target.join(line.strip).file?
|
|
166
|
+
end
|
|
167
|
+
backup.join("files").find do |saved|
|
|
168
|
+
next if saved.directory? || saved == backup.join("files")
|
|
169
|
+
rel = saved.relative_path_from(backup.join("files"))
|
|
170
|
+
dest = target.join(rel)
|
|
171
|
+
FileUtils.mkdir_p(dest.parent)
|
|
172
|
+
FileUtils.cp(saved, dest)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
marker = target.join(".spwn_synced_paths")
|
|
176
|
+
if backup.join("marker").file?
|
|
177
|
+
FileUtils.cp(backup.join("marker"), marker)
|
|
178
|
+
else
|
|
179
|
+
marker.delete if marker.file?
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
FileUtils.rm_rf(backup)
|
|
183
|
+
puts "Rolled back the most recent lesson sync."
|
|
184
|
+
0
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
private
|
|
188
|
+
|
|
189
|
+
def parse_args(argv)
|
|
190
|
+
lesson_folder = nil
|
|
191
|
+
into_folder = nil
|
|
192
|
+
force = false
|
|
193
|
+
|
|
194
|
+
i = 0
|
|
195
|
+
while i < argv.length
|
|
196
|
+
arg = argv[i]
|
|
197
|
+
case arg
|
|
198
|
+
when "--into"
|
|
199
|
+
i += 1
|
|
200
|
+
into_folder = argv[i]
|
|
201
|
+
when "--force", "-f"
|
|
202
|
+
force = true
|
|
203
|
+
when /\A-/
|
|
204
|
+
puts "Oops: I do not understand #{arg}."
|
|
205
|
+
return [nil, nil, false]
|
|
206
|
+
else
|
|
207
|
+
lesson_folder ||= arg
|
|
208
|
+
end
|
|
209
|
+
i += 1
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
[lesson_folder, into_folder, force]
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def parse_rollback_args(argv)
|
|
216
|
+
i = argv.index("--into")
|
|
217
|
+
i && argv[i + 1]
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def backup_existing(backup, dest, rel)
|
|
221
|
+
saved = backup.join("files", rel)
|
|
222
|
+
FileUtils.mkdir_p(saved.parent)
|
|
223
|
+
FileUtils.cp(dest, saved)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def copy_file(source, dest)
|
|
227
|
+
FileUtils.mkdir_p(dest.parent)
|
|
228
|
+
FileUtils.cp(source, dest)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def agree?(question)
|
|
232
|
+
loop do
|
|
233
|
+
print "#{question} (y/n): "
|
|
234
|
+
answer = STDIN.gets
|
|
235
|
+
return false if answer.nil?
|
|
236
|
+
|
|
237
|
+
answer = answer.strip.downcase
|
|
238
|
+
return true if answer == "y"
|
|
239
|
+
return false if answer == "n"
|
|
240
|
+
puts "Please type y or n."
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
data/lib/spawnpoint.rb
ADDED
data/spawnpoint.gemspec
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/spawnpoint/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "spawnpoint"
|
|
7
|
+
spec.version = Spawnpoint::VERSION
|
|
8
|
+
spec.authors = ["bebekim"]
|
|
9
|
+
spec.summary = "A child-friendly Git mask for learning game programming"
|
|
10
|
+
spec.description = "spwn is a thin wrapper around Git that renames Git " \
|
|
11
|
+
"commands into friendlier, game-like language for kids " \
|
|
12
|
+
"learning game programming."
|
|
13
|
+
spec.homepage = "https://github.com/bebekim/spawnpoint"
|
|
14
|
+
spec.license = "MIT"
|
|
15
|
+
spec.required_ruby_version = ">= 3.0"
|
|
16
|
+
|
|
17
|
+
spec.files = Dir.chdir(__dir__) { `git ls-files -z`.split("\x0") }
|
|
18
|
+
spec.bindir = "exe"
|
|
19
|
+
spec.executables = ["spwn"]
|
|
20
|
+
spec.require_paths = ["lib"]
|
|
21
|
+
|
|
22
|
+
spec.metadata = {
|
|
23
|
+
"source_code_uri" => "https://github.com/bebekim/spawnpoint",
|
|
24
|
+
"rubygems_mfa_required" => "true"
|
|
25
|
+
}
|
|
26
|
+
end
|
data/test/test_cli.rb
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
require "test_helper"
|
|
2
|
+
require "spawnpoint/cli"
|
|
3
|
+
|
|
4
|
+
class TestCli < Minitest::Test
|
|
5
|
+
def run_cli(argv)
|
|
6
|
+
capture_io { @status = Spawnpoint::CLI.run(argv) }
|
|
7
|
+
@status
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def test_no_arguments_prints_help_and_exits_zero
|
|
11
|
+
assert_equal 0, run_cli([])
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def test_help_flag_exits_zero
|
|
15
|
+
assert_equal 0, run_cli(["--help"])
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def test_version_flag_exits_zero
|
|
19
|
+
out, = capture_io { Spawnpoint::CLI.run(["--version"]) }
|
|
20
|
+
assert_includes out, "0.2.0"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def test_unknown_command_exits_one
|
|
24
|
+
assert_equal 1, run_cli(["teleport"])
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def test_save_without_message_exits_one
|
|
28
|
+
assert_equal 1, run_cli(["save"])
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def test_commit_without_message_exits_one
|
|
32
|
+
assert_equal 1, run_cli(["commit"])
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def test_hop_without_arguments_exits_one
|
|
36
|
+
assert_equal 1, run_cli(["hop"])
|
|
37
|
+
end
|
|
38
|
+
end
|
data/test/test_helper.rb
ADDED