smart_box 0.1.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.
- checksums.yaml +7 -0
- data/README.md +144 -0
- data/bin/smart_box +7 -0
- data/lib/smart_box/box.rb +381 -0
- data/lib/smart_box/cli.rb +412 -0
- data/lib/smart_box/command_result.rb +25 -0
- data/lib/smart_box/errors.rb +13 -0
- data/lib/smart_box/metadata.rb +124 -0
- data/lib/smart_box/modes/copy_mode.rb +106 -0
- data/lib/smart_box/modes/git_worktree_mode.rb +99 -0
- data/lib/smart_box/runner.rb +145 -0
- data/lib/smart_box/version.rb +3 -0
- data/lib/smart_box.rb +10 -0
- metadata +56 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../smart_box"
|
|
4
|
+
require "optparse"
|
|
5
|
+
|
|
6
|
+
module SmartBox
|
|
7
|
+
module CLI
|
|
8
|
+
def self.run(argv = ARGV)
|
|
9
|
+
parse_options(argv)
|
|
10
|
+
command = argv.first
|
|
11
|
+
|
|
12
|
+
case command
|
|
13
|
+
when "--version", "-v"
|
|
14
|
+
puts "smart_box #{SmartBox::VERSION}"
|
|
15
|
+
when "--help", "-h", nil
|
|
16
|
+
print_help
|
|
17
|
+
when "create"
|
|
18
|
+
cmd_create(argv)
|
|
19
|
+
when "list"
|
|
20
|
+
cmd_list(argv)
|
|
21
|
+
when "status"
|
|
22
|
+
cmd_status(argv)
|
|
23
|
+
when "run"
|
|
24
|
+
cmd_run(argv)
|
|
25
|
+
when "checkpoint"
|
|
26
|
+
cmd_checkpoint(argv)
|
|
27
|
+
when "checkpoints"
|
|
28
|
+
cmd_checkpoints(argv)
|
|
29
|
+
when "rollback"
|
|
30
|
+
cmd_rollback(argv)
|
|
31
|
+
when "diff"
|
|
32
|
+
cmd_diff(argv)
|
|
33
|
+
when "export-patch"
|
|
34
|
+
cmd_export_patch(argv)
|
|
35
|
+
when "apply"
|
|
36
|
+
cmd_apply(argv)
|
|
37
|
+
when "discard"
|
|
38
|
+
cmd_discard(argv)
|
|
39
|
+
else
|
|
40
|
+
puts "Unknown command: #{command}"
|
|
41
|
+
print_help
|
|
42
|
+
exit 1
|
|
43
|
+
end
|
|
44
|
+
rescue SmartBox::Error => e
|
|
45
|
+
$stderr.puts "Error: #{e.message}"
|
|
46
|
+
exit 2
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# --- Command implementations ---
|
|
50
|
+
|
|
51
|
+
def self.cmd_create(argv)
|
|
52
|
+
opts = parse_create(argv)
|
|
53
|
+
source = opts[:source] || "."
|
|
54
|
+
id = opts[:id]
|
|
55
|
+
mode = opts[:mode] || "copy"
|
|
56
|
+
name = opts[:name]
|
|
57
|
+
|
|
58
|
+
unless id
|
|
59
|
+
$stderr.puts "Error: --id is required"
|
|
60
|
+
exit 1
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
unless %w[copy git-worktree].include?(mode)
|
|
64
|
+
$stderr.puts "Error: --mode must be 'copy' or 'git-worktree'"
|
|
65
|
+
exit 1
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
box = SmartBox::Box.create(source: source, id: id, mode: mode, name: name)
|
|
69
|
+
|
|
70
|
+
puts "Box created:"
|
|
71
|
+
puts " id: #{box.id}"
|
|
72
|
+
puts " mode: #{box.mode}"
|
|
73
|
+
puts " workspace: #{box.workspace_path}"
|
|
74
|
+
puts " checkpoint: cp-001 initial"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def self.cmd_list(argv)
|
|
78
|
+
opts = parse_source(argv)
|
|
79
|
+
source = opts[:source] || "."
|
|
80
|
+
|
|
81
|
+
boxes = SmartBox::Box.list(source: source)
|
|
82
|
+
|
|
83
|
+
if boxes.empty?
|
|
84
|
+
puts "No boxes found."
|
|
85
|
+
return
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
puts "ID MODE STATUS UPDATED_AT"
|
|
89
|
+
boxes.each do |b|
|
|
90
|
+
updated = b["updated_at"]
|
|
91
|
+
updated = updated[0..15] if updated.is_a?(String) && updated.length > 16
|
|
92
|
+
printf "%-10s %-12s %-8s %s\n",
|
|
93
|
+
b["id"], b["mode"], b["status"], updated
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def self.cmd_status(argv)
|
|
98
|
+
opts = parse_box_opts(argv, require_id: true)
|
|
99
|
+
box = SmartBox::Box.load(source: opts[:source] || ".", id: opts[:id])
|
|
100
|
+
s = box.status_summary
|
|
101
|
+
|
|
102
|
+
puts "Box: #{s['id']}"
|
|
103
|
+
puts "Mode: #{s['mode']}"
|
|
104
|
+
puts "Status: #{s['status']}"
|
|
105
|
+
puts "Workspace: #{s['workspace']}"
|
|
106
|
+
puts
|
|
107
|
+
|
|
108
|
+
if s["changed_files"]&.any?
|
|
109
|
+
puts "Changed files:"
|
|
110
|
+
s["changed_files"].each { |f| puts " #{f}" }
|
|
111
|
+
else
|
|
112
|
+
puts "No changed files."
|
|
113
|
+
end
|
|
114
|
+
puts
|
|
115
|
+
|
|
116
|
+
if s["checkpoints"]&.any?
|
|
117
|
+
puts "Checkpoints:"
|
|
118
|
+
s["checkpoints"].each do |cp|
|
|
119
|
+
cp.each { |id, name| puts " #{id} #{name}" }
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def self.cmd_run(argv)
|
|
125
|
+
argv.shift # remove "run" subcommand name
|
|
126
|
+
opts = parse_run(argv)
|
|
127
|
+
id = opts[:id]
|
|
128
|
+
|
|
129
|
+
unless id
|
|
130
|
+
$stderr.puts "Error: --id is required"
|
|
131
|
+
exit 1
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
command = argv.join(" ")
|
|
135
|
+
if command.empty?
|
|
136
|
+
$stderr.puts "Error: command is required (use -- before command args)"
|
|
137
|
+
exit 1
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
box = SmartBox::Box.load(source: opts[:source] || ".", id: id)
|
|
141
|
+
result = box.run(command, allow_dangerous: opts[:allow_dangerous])
|
|
142
|
+
|
|
143
|
+
$stdout.write(result.stdout)
|
|
144
|
+
$stderr.write(result.stderr) unless result.stderr.empty?
|
|
145
|
+
exit result.exit_code
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def self.cmd_checkpoint(argv)
|
|
149
|
+
opts = parse_checkpoint(argv)
|
|
150
|
+
id = opts[:id]
|
|
151
|
+
name = opts[:name]
|
|
152
|
+
|
|
153
|
+
unless id
|
|
154
|
+
$stderr.puts "Error: --id is required"
|
|
155
|
+
exit 1
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
unless name
|
|
159
|
+
$stderr.puts "Error: --name is required"
|
|
160
|
+
exit 1
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
box = SmartBox::Box.load(source: opts[:source] || ".", id: id)
|
|
164
|
+
cp = box.checkpoint(name)
|
|
165
|
+
|
|
166
|
+
puts "Checkpoint created:"
|
|
167
|
+
puts " id: #{cp[:id]}"
|
|
168
|
+
puts " name: #{cp[:name]}"
|
|
169
|
+
puts " commit: #{cp[:commit][0..7]}"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def self.cmd_checkpoints(argv)
|
|
173
|
+
opts = parse_box_opts(argv, require_id: true)
|
|
174
|
+
box = SmartBox::Box.load(source: opts[:source] || ".", id: opts[:id])
|
|
175
|
+
cps = box.checkpoints
|
|
176
|
+
|
|
177
|
+
if cps.empty?
|
|
178
|
+
puts "No checkpoints."
|
|
179
|
+
return
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
cps.each do |cp|
|
|
183
|
+
puts "#{cp['id']} #{cp['name']}"
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def self.cmd_rollback(argv)
|
|
188
|
+
opts = parse_rollback(argv)
|
|
189
|
+
id = opts[:id]
|
|
190
|
+
checkpoint = opts[:checkpoint]
|
|
191
|
+
|
|
192
|
+
unless id
|
|
193
|
+
$stderr.puts "Error: --id is required"
|
|
194
|
+
exit 1
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
unless checkpoint
|
|
198
|
+
$stderr.puts "Error: --checkpoint is required"
|
|
199
|
+
exit 1
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
box = SmartBox::Box.load(source: opts[:source] || ".", id: id)
|
|
203
|
+
box.rollback(checkpoint)
|
|
204
|
+
|
|
205
|
+
puts "Rolled back:"
|
|
206
|
+
puts " box: #{id}"
|
|
207
|
+
puts " checkpoint: #{checkpoint}"
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def self.cmd_diff(argv)
|
|
211
|
+
opts = parse_diff(argv)
|
|
212
|
+
box = SmartBox::Box.load(source: opts[:source] || ".", id: opts[:id])
|
|
213
|
+
|
|
214
|
+
unless box.metadata.id
|
|
215
|
+
$stderr.puts "Error: --id is required"
|
|
216
|
+
exit 1
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
puts box.diff(from: opts[:from], to: opts[:to])
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def self.cmd_export_patch(argv)
|
|
223
|
+
opts = parse_export_patch(argv)
|
|
224
|
+
|
|
225
|
+
unless opts[:id]
|
|
226
|
+
$stderr.puts "Error: --id is required"
|
|
227
|
+
exit 1
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
unless opts[:output]
|
|
231
|
+
$stderr.puts "Error: --output is required"
|
|
232
|
+
exit 1
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
box = SmartBox::Box.load(source: opts[:source] || ".", id: opts[:id])
|
|
236
|
+
result = box.export_patch(output: opts[:output], from: opts[:from], to: opts[:to])
|
|
237
|
+
|
|
238
|
+
puts "Patch exported:"
|
|
239
|
+
puts " #{result[:output]}"
|
|
240
|
+
puts " #{result[:size]} bytes"
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def self.cmd_apply(argv)
|
|
244
|
+
opts = parse_apply(argv)
|
|
245
|
+
|
|
246
|
+
unless opts[:id]
|
|
247
|
+
$stderr.puts "Error: --id is required"
|
|
248
|
+
exit 1
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
box = SmartBox::Box.load(source: opts[:source] || ".", id: opts[:id])
|
|
252
|
+
|
|
253
|
+
if opts[:dry_run]
|
|
254
|
+
result = box.apply(dry_run: true)
|
|
255
|
+
puts "Dry run:"
|
|
256
|
+
puts " patch size: #{result[:patch_size]} bytes"
|
|
257
|
+
puts " Would apply #{result[:patch_size] > 0 ? 'changes' : 'no changes'} to #{box.source_path}"
|
|
258
|
+
return
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Check if source is clean before applying
|
|
262
|
+
unless opts[:force] || box.source_clean?
|
|
263
|
+
$stderr.puts "Error: Source project has uncommitted changes."
|
|
264
|
+
$stderr.puts "Use --force to apply anyway, or --dry-run to preview."
|
|
265
|
+
exit 1
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
result = box.apply(force: opts[:force])
|
|
269
|
+
puts "Patch applied:"
|
|
270
|
+
puts result
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def self.cmd_discard(argv)
|
|
274
|
+
opts = parse_box_opts(argv, require_id: true)
|
|
275
|
+
box = SmartBox::Box.load(source: opts[:source] || ".", id: opts[:id])
|
|
276
|
+
result = box.discard
|
|
277
|
+
|
|
278
|
+
puts "Box discarded:"
|
|
279
|
+
puts " id: #{result[:id]}"
|
|
280
|
+
puts " status: #{result[:status]}"
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# --- Option parsers ---
|
|
284
|
+
|
|
285
|
+
def self.parse_create(argv)
|
|
286
|
+
opts = {}
|
|
287
|
+
OptionParser.new do |p|
|
|
288
|
+
p.on("--source PATH") { |v| opts[:source] = v }
|
|
289
|
+
p.on("--id ID") { |v| opts[:id] = v }
|
|
290
|
+
p.on("--mode MODE") { |v| opts[:mode] = v }
|
|
291
|
+
p.on("--name NAME") { |v| opts[:name] = v }
|
|
292
|
+
end.parse!(argv)
|
|
293
|
+
opts
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def self.parse_source(argv)
|
|
297
|
+
opts = {}
|
|
298
|
+
OptionParser.new do |p|
|
|
299
|
+
p.on("--source PATH") { |v| opts[:source] = v }
|
|
300
|
+
end.parse!(argv)
|
|
301
|
+
opts
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def self.parse_box_opts(argv, require_id: false)
|
|
305
|
+
opts = {}
|
|
306
|
+
OptionParser.new do |p|
|
|
307
|
+
p.on("--source PATH") { |v| opts[:source] = v }
|
|
308
|
+
p.on("--id ID") { |v| opts[:id] = v }
|
|
309
|
+
end.parse!(argv)
|
|
310
|
+
|
|
311
|
+
if require_id && !opts[:id]
|
|
312
|
+
$stderr.puts "Error: --id is required"
|
|
313
|
+
exit 1
|
|
314
|
+
end
|
|
315
|
+
opts
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def self.parse_run(argv)
|
|
319
|
+
opts = {}
|
|
320
|
+
OptionParser.new do |p|
|
|
321
|
+
p.on("--source PATH") { |v| opts[:source] = v }
|
|
322
|
+
p.on("--id ID") { |v| opts[:id] = v }
|
|
323
|
+
p.on("--allow-dangerous") { |v| opts[:allow_dangerous] = true }
|
|
324
|
+
end.parse!(argv)
|
|
325
|
+
opts
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def self.parse_checkpoint(argv)
|
|
329
|
+
opts = {}
|
|
330
|
+
OptionParser.new do |p|
|
|
331
|
+
p.on("--source PATH") { |v| opts[:source] = v }
|
|
332
|
+
p.on("--id ID") { |v| opts[:id] = v }
|
|
333
|
+
p.on("--name NAME") { |v| opts[:name] = v }
|
|
334
|
+
end.parse!(argv)
|
|
335
|
+
opts
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def self.parse_rollback(argv)
|
|
339
|
+
opts = {}
|
|
340
|
+
OptionParser.new do |p|
|
|
341
|
+
p.on("--source PATH") { |v| opts[:source] = v }
|
|
342
|
+
p.on("--id ID") { |v| opts[:id] = v }
|
|
343
|
+
p.on("--checkpoint CP") { |v| opts[:checkpoint] = v }
|
|
344
|
+
end.parse!(argv)
|
|
345
|
+
opts
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def self.parse_diff(argv)
|
|
349
|
+
opts = {}
|
|
350
|
+
OptionParser.new do |p|
|
|
351
|
+
p.on("--source PATH") { |v| opts[:source] = v }
|
|
352
|
+
p.on("--id ID") { |v| opts[:id] = v }
|
|
353
|
+
p.on("--from CP") { |v| opts[:from] = v }
|
|
354
|
+
p.on("--to CP") { |v| opts[:to] = v }
|
|
355
|
+
end.parse!(argv)
|
|
356
|
+
opts
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def self.parse_export_patch(argv)
|
|
360
|
+
opts = {}
|
|
361
|
+
OptionParser.new do |p|
|
|
362
|
+
p.on("--source PATH") { |v| opts[:source] = v }
|
|
363
|
+
p.on("--id ID") { |v| opts[:id] = v }
|
|
364
|
+
p.on("--output FILE") { |v| opts[:output] = v }
|
|
365
|
+
p.on("--from CP") { |v| opts[:from] = v }
|
|
366
|
+
p.on("--to CP") { |v| opts[:to] = v }
|
|
367
|
+
end.parse!(argv)
|
|
368
|
+
opts
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def self.parse_apply(argv)
|
|
372
|
+
opts = {}
|
|
373
|
+
OptionParser.new do |p|
|
|
374
|
+
p.on("--source PATH") { |v| opts[:source] = v }
|
|
375
|
+
p.on("--id ID") { |v| opts[:id] = v }
|
|
376
|
+
p.on("--dry-run") { |v| opts[:dry_run] = true }
|
|
377
|
+
p.on("--force") { |v| opts[:force] = true }
|
|
378
|
+
end.parse!(argv)
|
|
379
|
+
opts
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def self.parse_options(argv)
|
|
383
|
+
# Global options stripped before command dispatch — handled in `run`
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def self.print_help
|
|
387
|
+
puts <<~HELP
|
|
388
|
+
smart_box #{SmartBox::VERSION}
|
|
389
|
+
|
|
390
|
+
Usage:
|
|
391
|
+
smart_box <command> [options]
|
|
392
|
+
|
|
393
|
+
Commands:
|
|
394
|
+
create Create a new box
|
|
395
|
+
list List boxes
|
|
396
|
+
status Show box status
|
|
397
|
+
run Run a command in a box
|
|
398
|
+
checkpoint Create a checkpoint
|
|
399
|
+
checkpoints List checkpoints
|
|
400
|
+
rollback Rollback to a checkpoint
|
|
401
|
+
diff Show diff
|
|
402
|
+
export-patch Export patch file
|
|
403
|
+
apply Apply patch to source project
|
|
404
|
+
discard Discard a box
|
|
405
|
+
|
|
406
|
+
Options:
|
|
407
|
+
--version, -v Show version
|
|
408
|
+
--help, -h Show this help
|
|
409
|
+
HELP
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SmartBox
|
|
4
|
+
class CommandResult
|
|
5
|
+
attr_reader :command, :cwd, :stdout, :stderr, :exit_code, :started_at, :ended_at
|
|
6
|
+
|
|
7
|
+
def initialize(command:, cwd:, stdout:, stderr:, exit_code:, started_at:, ended_at:)
|
|
8
|
+
@command = command
|
|
9
|
+
@cwd = cwd
|
|
10
|
+
@stdout = stdout
|
|
11
|
+
@stderr = stderr
|
|
12
|
+
@exit_code = exit_code
|
|
13
|
+
@started_at = started_at
|
|
14
|
+
@ended_at = ended_at
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def success?
|
|
18
|
+
@exit_code == 0
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def elapsed_ms
|
|
22
|
+
((@ended_at - @started_at) * 1000).to_i
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SmartBox
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
class BoxAlreadyExistsError < Error; end
|
|
7
|
+
class BoxNotFoundError < Error; end
|
|
8
|
+
class InvalidModeError < Error; end
|
|
9
|
+
class DirtySourceError < Error; end
|
|
10
|
+
class CheckpointNotFoundError < Error; end
|
|
11
|
+
class DangerousCommandError < Error; end
|
|
12
|
+
class PatchApplyError < Error; end
|
|
13
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module SmartBox
|
|
7
|
+
class Metadata
|
|
8
|
+
attr_reader :path, :data
|
|
9
|
+
|
|
10
|
+
def initialize(path)
|
|
11
|
+
@path = path
|
|
12
|
+
@data = {}
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def load!
|
|
16
|
+
if File.exist?(@path)
|
|
17
|
+
@data = YAML.safe_load_file(@path, permitted_classes: [Time]) || {}
|
|
18
|
+
end
|
|
19
|
+
self
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def save!
|
|
23
|
+
dir = File.dirname(@path)
|
|
24
|
+
FileUtils.mkdir_p(dir)
|
|
25
|
+
File.write(@path, YAML.dump(@data))
|
|
26
|
+
self
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# --- Convenience accessors ---
|
|
30
|
+
|
|
31
|
+
def id
|
|
32
|
+
@data["id"]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def id=(val)
|
|
36
|
+
@data["id"] = val
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def name
|
|
40
|
+
@data["name"]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def name=(val)
|
|
44
|
+
@data["name"] = val
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def mode
|
|
48
|
+
@data["mode"]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def mode=(val)
|
|
52
|
+
@data["mode"] = val
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def source_path
|
|
56
|
+
@data["source_path"]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def source_path=(val)
|
|
60
|
+
@data["source_path"] = val
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def workspace_path
|
|
64
|
+
@data["workspace_path"]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def workspace_path=(val)
|
|
68
|
+
@data["workspace_path"] = val
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def status
|
|
72
|
+
@data["status"] || "active"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def status=(val)
|
|
76
|
+
@data["status"] = val
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def created_at
|
|
80
|
+
@data["created_at"]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def created_at=(val)
|
|
84
|
+
@data["created_at"] = val
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def updated_at
|
|
88
|
+
@data["updated_at"] || Time.now.utc.iso8601
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def updated_at=(val)
|
|
92
|
+
@data["updated_at"] = val
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def base
|
|
96
|
+
@data["base"] ||= {}
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def base=(val)
|
|
100
|
+
@data["base"] = val
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def checkpoints
|
|
104
|
+
@data["checkpoints"] ||= []
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def stats
|
|
108
|
+
@data["stats"] ||= {}
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def stats=(val)
|
|
112
|
+
@data["stats"] = val
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def add_checkpoint(id:, name:, git_commit:, created_at: Time.now.utc.iso8601)
|
|
116
|
+
checkpoints << {
|
|
117
|
+
"id" => id,
|
|
118
|
+
"name" => name,
|
|
119
|
+
"git_commit" => git_commit,
|
|
120
|
+
"created_at" => created_at
|
|
121
|
+
}
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module SmartBox
|
|
7
|
+
module Modes
|
|
8
|
+
# copy mode: copies the source project into an isolated workspace directory,
|
|
9
|
+
# then initializes a git repo inside the workspace for checkpoint support.
|
|
10
|
+
class CopyMode
|
|
11
|
+
# Directories and files to exclude when copying the source project
|
|
12
|
+
COPY_EXCLUDES = %w[
|
|
13
|
+
.git
|
|
14
|
+
.smart_box
|
|
15
|
+
node_modules
|
|
16
|
+
vendor/bundle
|
|
17
|
+
.bundle
|
|
18
|
+
tmp
|
|
19
|
+
log
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
EXCLUDE_DIRS = %w[
|
|
23
|
+
.git
|
|
24
|
+
.smart_box
|
|
25
|
+
node_modules
|
|
26
|
+
tmp
|
|
27
|
+
log
|
|
28
|
+
].freeze
|
|
29
|
+
|
|
30
|
+
attr_reader :source_path, :workspace_path
|
|
31
|
+
|
|
32
|
+
def initialize(source_path:, workspace_path:)
|
|
33
|
+
@source_path = File.expand_path(source_path)
|
|
34
|
+
@workspace_path = File.expand_path(workspace_path)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# --- entry point ---
|
|
38
|
+
|
|
39
|
+
def setup
|
|
40
|
+
validate_source!
|
|
41
|
+
create_workspace!
|
|
42
|
+
copy_source!
|
|
43
|
+
init_git!
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def teardown
|
|
47
|
+
FileUtils.rm_rf(@workspace_path) if Dir.exist?(@workspace_path)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def validate_source!
|
|
53
|
+
unless Dir.exist?(@source_path)
|
|
54
|
+
raise SmartBox::Error, "Source directory not found: #{@source_path}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# For copy mode we allow non-git sources, no extra check needed.
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def create_workspace!
|
|
61
|
+
FileUtils.mkdir_p(@workspace_path)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def copy_source!
|
|
65
|
+
# Use FileUtils.cp_r with a filter to skip excluded directories.
|
|
66
|
+
# Walk through each entry in the source directory
|
|
67
|
+
Dir.each_child(@source_path) do |entry|
|
|
68
|
+
# Skip excluded dirs/files at the top level
|
|
69
|
+
next if EXCLUDE_DIRS.include?(entry)
|
|
70
|
+
|
|
71
|
+
# Skip vendor/bundle — it's a nested pattern; handle at top-level
|
|
72
|
+
# since we only walk one level deep via each_child
|
|
73
|
+
next if entry == "vendor"
|
|
74
|
+
|
|
75
|
+
src = File.join(@source_path, entry)
|
|
76
|
+
dest = File.join(@workspace_path, entry)
|
|
77
|
+
|
|
78
|
+
if File.directory?(src)
|
|
79
|
+
# vendor directory: copy but skip vendor/bundle
|
|
80
|
+
if entry == "vendor"
|
|
81
|
+
FileUtils.mkdir_p(dest)
|
|
82
|
+
Dir.each_child(src) do |sub|
|
|
83
|
+
next if sub == "bundle"
|
|
84
|
+
FileUtils.cp_r(File.join(src, sub), File.join(dest, sub))
|
|
85
|
+
end
|
|
86
|
+
else
|
|
87
|
+
FileUtils.cp_r(src, dest)
|
|
88
|
+
end
|
|
89
|
+
else
|
|
90
|
+
FileUtils.cp_r(src, dest)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def init_git!
|
|
96
|
+
Dir.chdir(@workspace_path) do
|
|
97
|
+
system("git", "init", out: File::NULL, err: File::NULL)
|
|
98
|
+
system("git", "config", "user.email", "smart_box@localhost", out: File::NULL, err: File::NULL)
|
|
99
|
+
system("git", "config", "user.name", "smart_box", out: File::NULL, err: File::NULL)
|
|
100
|
+
system("git", "add", "-A", out: File::NULL, err: File::NULL)
|
|
101
|
+
system("git", "commit", "-m", "smart_box initial checkpoint", out: File::NULL, err: File::NULL)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|