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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: fdd527c54db2a72e0d40528a4fb248a712f06a8f28bd11e7b81c995963e1a4e1
|
|
4
|
+
data.tar.gz: b277525806bbccc5ad7a979733d0e9fcaa7ad483f8c8910e5d5105ea78f94712
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 61675f220c4dda1cae98b9091201057351b9abd65dfe4d4c4c906aa4be4e926e0091619079034a88fdb8209c17e7fc434014b432bb8397b869fcddd413c40ca2
|
|
7
|
+
data.tar.gz: b14f1b4116d23c01e540eac1275a5048e8decc279034ee33fde8f5b374314402fb2ec0a291a20762cef9e553a1c992425bdb9c19509f2b7bc51ec21371b8c268
|
data/README.md
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# smart_box
|
|
2
|
+
|
|
3
|
+
## What is smart_box?
|
|
4
|
+
|
|
5
|
+
smart_box is a local reversible sandbox system for Coding Agent task execution.
|
|
6
|
+
|
|
7
|
+
Its core goal: allow Coding Agents (SmartExpert, SmartCoder, etc.) to perform file
|
|
8
|
+
modifications, command execution, dependency installation, experimental fixes, code
|
|
9
|
+
generation, diff viewing, checkpoint rollback, and patch export — without directly
|
|
10
|
+
polluting the real project directory.
|
|
11
|
+
|
|
12
|
+
## Why smart_box?
|
|
13
|
+
|
|
14
|
+
When a Coding Agent runs autonomously, it may:
|
|
15
|
+
|
|
16
|
+
- Modify source files incorrectly
|
|
17
|
+
- Install conflicting dependencies
|
|
18
|
+
- Remove critical files
|
|
19
|
+
- Produce untested patches
|
|
20
|
+
|
|
21
|
+
smart_box provides a safety net: every operation happens inside an isolated box
|
|
22
|
+
first. The user can inspect, verify, and then explicitly apply changes to the
|
|
23
|
+
real project.
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
gem install smart_box
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Or via Bundler:
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
gem "smart_box"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Quick Start
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
# Create a box from current project (copy mode)
|
|
41
|
+
smart_box create --source . --id task-001 --mode copy
|
|
42
|
+
|
|
43
|
+
# Run a command inside the box
|
|
44
|
+
smart_box run --id task-001 -- bundle install
|
|
45
|
+
|
|
46
|
+
# Create a checkpoint
|
|
47
|
+
smart_box checkpoint --id task-001 --name "after bundle install"
|
|
48
|
+
|
|
49
|
+
# View changes
|
|
50
|
+
smart_box diff --id task-001
|
|
51
|
+
|
|
52
|
+
# Export patch
|
|
53
|
+
smart_box export-patch --id task-001 --output fix.patch
|
|
54
|
+
|
|
55
|
+
# Rollback to initial state
|
|
56
|
+
smart_box rollback --id task-001 --checkpoint cp-001 --mode copy
|
|
57
|
+
|
|
58
|
+
# Apply to source project
|
|
59
|
+
smart_box apply --id task-001
|
|
60
|
+
|
|
61
|
+
# Discard the box
|
|
62
|
+
smart_box discard --id task-001
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Concepts
|
|
66
|
+
|
|
67
|
+
- **Source Project**: The original project directory. smart_box never modifies it directly.
|
|
68
|
+
- **Box**: An isolated execution space for one task or experiment.
|
|
69
|
+
- **Checkpoint**: A saved state inside a box, allowing rollback.
|
|
70
|
+
- **Diff**: Changes between the box's current state and a reference point.
|
|
71
|
+
- **Patch**: A portable changeset exported from a box for manual review and application.
|
|
72
|
+
|
|
73
|
+
## CLI Usage
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
smart_box <command> [options]
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
| Command | Description |
|
|
80
|
+
|---------------|--------------------------------------|
|
|
81
|
+
| create | Create a new box |
|
|
82
|
+
| list | List all boxes |
|
|
83
|
+
| status | Show box status |
|
|
84
|
+
| run | Execute a command inside a box |
|
|
85
|
+
| checkpoint | Create a checkpoint |
|
|
86
|
+
| checkpoints | List checkpoints in a box |
|
|
87
|
+
| rollback | Rollback to a checkpoint |
|
|
88
|
+
| diff | Show diff against checkpoint |
|
|
89
|
+
| export-patch | Export patch to a file |
|
|
90
|
+
| apply | Apply box changes to source project |
|
|
91
|
+
| discard | Discard a box and its workspace |
|
|
92
|
+
|
|
93
|
+
## Ruby API Usage
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
require "smart_box"
|
|
97
|
+
|
|
98
|
+
box = SmartBox::Box.create(
|
|
99
|
+
source: ".",
|
|
100
|
+
id: "task-001",
|
|
101
|
+
mode: :copy,
|
|
102
|
+
name: "fix bundler conflict"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
result = box.run("bundle install")
|
|
106
|
+
box.checkpoint("after bundle install")
|
|
107
|
+
puts box.diff
|
|
108
|
+
box.rollback("cp-001")
|
|
109
|
+
box.export_patch("fix.patch")
|
|
110
|
+
box.apply(dry_run: true)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Modes
|
|
114
|
+
|
|
115
|
+
### copy mode
|
|
116
|
+
|
|
117
|
+
Copies the source project (excluding `.git`, `node_modules`, etc.) into an
|
|
118
|
+
isolated workspace. Simple and works even if the source is not a git repository.
|
|
119
|
+
|
|
120
|
+
### git-worktree mode
|
|
121
|
+
|
|
122
|
+
Uses `git worktree add` to create an isolated working directory. Faster for
|
|
123
|
+
large projects and natively compatible with git workflows.
|
|
124
|
+
|
|
125
|
+
## Safety Notes
|
|
126
|
+
|
|
127
|
+
- Dangerous commands (`rm -rf /`, `sudo`, etc.) are blocked by default.
|
|
128
|
+
- All commands are restricted to the box workspace.
|
|
129
|
+
- The source project is never modified without explicit `apply`.
|
|
130
|
+
- All paths use absolute normalization to prevent path traversal.
|
|
131
|
+
|
|
132
|
+
## Roadmap
|
|
133
|
+
|
|
134
|
+
- [x] copy mode
|
|
135
|
+
- [x] git-worktree mode
|
|
136
|
+
- [ ] Docker / DevContainer mode
|
|
137
|
+
- [ ] Command policy configuration
|
|
138
|
+
- [ ] Network policy
|
|
139
|
+
- [ ] Resource limits
|
|
140
|
+
- [ ] Concurrent boxes
|
|
141
|
+
- [ ] SmartExpert TUI integration
|
|
142
|
+
- [ ] SmartCoder workflow integration
|
|
143
|
+
- [ ] MCP tool wrapper
|
|
144
|
+
- [ ] JSON-RPC server mode
|
data/bin/smart_box
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "time"
|
|
5
|
+
require_relative "errors"
|
|
6
|
+
require_relative "metadata"
|
|
7
|
+
require_relative "modes/copy_mode"
|
|
8
|
+
|
|
9
|
+
module SmartBox
|
|
10
|
+
class Box
|
|
11
|
+
attr_reader :id, :source_path, :workspace_path, :mode, :metadata
|
|
12
|
+
|
|
13
|
+
SMART_BOX_DIR = ".smart_box"
|
|
14
|
+
BOXES_DIR = File.join(SMART_BOX_DIR, "boxes")
|
|
15
|
+
|
|
16
|
+
def initialize(source_path:, id:, mode:, name: nil)
|
|
17
|
+
@id = id
|
|
18
|
+
@mode = mode.to_s
|
|
19
|
+
@source_path = File.expand_path(source_path)
|
|
20
|
+
@name = name
|
|
21
|
+
@box_dir = File.join(@source_path, BOXES_DIR, @id)
|
|
22
|
+
@workspace_path = File.join(@box_dir, "workspace")
|
|
23
|
+
@metadata_path = File.join(@box_dir, "metadata.yml")
|
|
24
|
+
@metadata = Metadata.new(@metadata_path)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# --- Class methods ---
|
|
28
|
+
|
|
29
|
+
def self.create(source:, id:, mode:, name: nil)
|
|
30
|
+
box = new(source_path: source, id: id, mode: mode, name: name)
|
|
31
|
+
|
|
32
|
+
if Dir.exist?(box.send(:box_dir))
|
|
33
|
+
raise BoxAlreadyExistsError, "Box '#{id}' already exists"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
FileUtils.mkdir_p(box.send(:box_dir))
|
|
37
|
+
FileUtils.mkdir_p(File.join(box.send(:box_dir), "logs"))
|
|
38
|
+
FileUtils.mkdir_p(File.join(box.send(:box_dir), "patches"))
|
|
39
|
+
FileUtils.mkdir_p(File.join(box.send(:box_dir), "checkpoints"))
|
|
40
|
+
|
|
41
|
+
mode_instance = box.send(:mode_instance)
|
|
42
|
+
mode_instance.setup
|
|
43
|
+
|
|
44
|
+
# Capture initial git commit hash
|
|
45
|
+
initial_commit = box.send(:git_latest_commit)
|
|
46
|
+
|
|
47
|
+
box.metadata.id = id
|
|
48
|
+
box.metadata.name = name
|
|
49
|
+
box.metadata.mode = mode.to_s
|
|
50
|
+
box.metadata.source_path = box.source_path
|
|
51
|
+
box.metadata.workspace_path = box.workspace_path
|
|
52
|
+
box.metadata.status = "active"
|
|
53
|
+
box.metadata.created_at = Time.now.utc.iso8601
|
|
54
|
+
box.metadata.updated_at = Time.now.utc.iso8601
|
|
55
|
+
box.metadata.base = {
|
|
56
|
+
"type" => mode.to_s,
|
|
57
|
+
"source_git_commit" => box.send(:source_git_commit),
|
|
58
|
+
"source_git_branch" => box.send(:source_git_branch)
|
|
59
|
+
}
|
|
60
|
+
box.metadata.add_checkpoint(
|
|
61
|
+
id: "cp-001",
|
|
62
|
+
name: "initial",
|
|
63
|
+
git_commit: initial_commit,
|
|
64
|
+
created_at: Time.now.utc.iso8601
|
|
65
|
+
)
|
|
66
|
+
box.metadata.stats = {
|
|
67
|
+
"commands_count" => 0,
|
|
68
|
+
"changed_files_count" => 0
|
|
69
|
+
}
|
|
70
|
+
box.metadata.save!
|
|
71
|
+
|
|
72
|
+
box
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.load(source:, id:)
|
|
76
|
+
box = new(source_path: source, id: id, mode: nil)
|
|
77
|
+
|
|
78
|
+
unless Dir.exist?(box.send(:box_dir))
|
|
79
|
+
raise BoxNotFoundError, "Box '#{id}' not found"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
box.metadata.load!
|
|
83
|
+
|
|
84
|
+
unless box.metadata.id
|
|
85
|
+
raise BoxNotFoundError, "Box '#{id}' metadata is missing 'id' field"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
box
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def self.list(source:)
|
|
92
|
+
boxes_path = File.join(File.expand_path(source), BOXES_DIR)
|
|
93
|
+
return [] unless Dir.exist?(boxes_path)
|
|
94
|
+
|
|
95
|
+
Dir.each_child(boxes_path).filter_map do |entry|
|
|
96
|
+
box_path = File.join(boxes_path, entry)
|
|
97
|
+
next unless Dir.exist?(box_path)
|
|
98
|
+
|
|
99
|
+
metadata_path = File.join(box_path, "metadata.yml")
|
|
100
|
+
next unless File.exist?(metadata_path)
|
|
101
|
+
|
|
102
|
+
meta = YAML.safe_load_file(metadata_path, permitted_classes: [Time])
|
|
103
|
+
next unless meta.is_a?(Hash)
|
|
104
|
+
|
|
105
|
+
{
|
|
106
|
+
"id" => meta["id"] || entry,
|
|
107
|
+
"mode" => meta["mode"] || "unknown",
|
|
108
|
+
"status" => meta["status"] || "unknown",
|
|
109
|
+
"updated_at" => meta["updated_at"] || "unknown"
|
|
110
|
+
}
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# --- Instance methods ---
|
|
115
|
+
|
|
116
|
+
def status_summary
|
|
117
|
+
{
|
|
118
|
+
"id" => @id,
|
|
119
|
+
"mode" => @mode || @metadata.mode,
|
|
120
|
+
"status" => @metadata.status,
|
|
121
|
+
"workspace" => @workspace_path,
|
|
122
|
+
"changed_files" => git_status_short,
|
|
123
|
+
"checkpoints" => @metadata.checkpoints.map { |cp| { cp["id"] => cp["name"] } }
|
|
124
|
+
}
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def git_status_short
|
|
128
|
+
return [] unless Dir.exist?(@workspace_path)
|
|
129
|
+
|
|
130
|
+
Dir.chdir(@workspace_path) do
|
|
131
|
+
out = `git status --porcelain 2>/dev/null`
|
|
132
|
+
return [] unless $?.success?
|
|
133
|
+
|
|
134
|
+
out.lines.map(&:chomp).reject(&:empty?)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def run(command, env: {}, timeout: nil, allow_dangerous: false)
|
|
139
|
+
runner.run(command, env: env, timeout: timeout, allow_dangerous: allow_dangerous).tap do
|
|
140
|
+
# Update metadata stats
|
|
141
|
+
@metadata.load!
|
|
142
|
+
stats = @metadata.stats
|
|
143
|
+
stats["commands_count"] = (stats["commands_count"] || 0) + 1
|
|
144
|
+
@metadata.updated_at = Time.now.utc.iso8601
|
|
145
|
+
@metadata.save!
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def checkpoint(name)
|
|
150
|
+
raise Error, "Workspace does not exist" unless Dir.exist?(@workspace_path)
|
|
151
|
+
|
|
152
|
+
Dir.chdir(@workspace_path) do
|
|
153
|
+
system("git", "add", "-A", out: File::NULL, err: File::NULL)
|
|
154
|
+
system("git", "commit", "--allow-empty", "-m", name, out: File::NULL, err: File::NULL)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
commit = git_latest_commit
|
|
158
|
+
cp_id = "cp-#{format('%03d', (@metadata.checkpoints.size + 1))}"
|
|
159
|
+
|
|
160
|
+
@metadata.load!
|
|
161
|
+
@metadata.add_checkpoint(
|
|
162
|
+
id: cp_id,
|
|
163
|
+
name: name,
|
|
164
|
+
git_commit: commit,
|
|
165
|
+
created_at: Time.now.utc.iso8601
|
|
166
|
+
)
|
|
167
|
+
@metadata.updated_at = Time.now.utc.iso8601
|
|
168
|
+
@metadata.save!
|
|
169
|
+
|
|
170
|
+
{ id: cp_id, name: name, commit: commit }
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def checkpoints
|
|
174
|
+
@metadata.load!
|
|
175
|
+
@metadata.checkpoints.map do |cp|
|
|
176
|
+
{ "id" => cp["id"], "name" => cp["name"] }
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def rollback(checkpoint_id)
|
|
181
|
+
cp = @metadata.checkpoints.detect { |c| c["id"] == checkpoint_id }
|
|
182
|
+
raise CheckpointNotFoundError, "Checkpoint '#{checkpoint_id}' not found" unless cp
|
|
183
|
+
|
|
184
|
+
Dir.chdir(@workspace_path) do
|
|
185
|
+
system("git", "reset", "--hard", cp["git_commit"], out: File::NULL, err: File::NULL)
|
|
186
|
+
system("git", "clean", "-fd", out: File::NULL, err: File::NULL)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
@metadata.load!
|
|
190
|
+
@metadata.updated_at = Time.now.utc.iso8601
|
|
191
|
+
@metadata.save!
|
|
192
|
+
|
|
193
|
+
{ box_id: @id, checkpoint_id: checkpoint_id }
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def diff(from: nil, to: nil)
|
|
197
|
+
raise Error, "Workspace does not exist" unless Dir.exist?(@workspace_path)
|
|
198
|
+
|
|
199
|
+
Dir.chdir(@workspace_path) do
|
|
200
|
+
# Stage everything so untracked files appear in diff
|
|
201
|
+
system("git", "add", "-A", out: File::NULL, err: File::NULL)
|
|
202
|
+
|
|
203
|
+
from_commit = if from
|
|
204
|
+
resolve_checkpoint_commit(from)
|
|
205
|
+
else
|
|
206
|
+
init = @metadata.checkpoints.first
|
|
207
|
+
init ? init["git_commit"] : "HEAD~1"
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
to_commit = to ? resolve_checkpoint_commit(to) : nil
|
|
211
|
+
|
|
212
|
+
if to_commit
|
|
213
|
+
`git diff #{from_commit} #{to_commit} 2>/dev/null`
|
|
214
|
+
else
|
|
215
|
+
`git diff --cached #{from_commit} 2>/dev/null`
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def export_patch(output:, from: nil, to: nil)
|
|
221
|
+
output_path = if output.start_with?("/")
|
|
222
|
+
output
|
|
223
|
+
else
|
|
224
|
+
File.join(Dir.pwd, output)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
patch_content = diff(from: from, to: to)
|
|
228
|
+
File.write(output_path, patch_content)
|
|
229
|
+
|
|
230
|
+
{ output: output_path, size: patch_content.bytesize }
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def apply(dry_run: false, force: false)
|
|
234
|
+
raise Error, "Workspace does not exist" unless Dir.exist?(@workspace_path)
|
|
235
|
+
|
|
236
|
+
patch_content = diff(from: @metadata.checkpoints.first&.dig("git_commit"))
|
|
237
|
+
|
|
238
|
+
if dry_run
|
|
239
|
+
return { dry_run: true, patch_size: patch_content.bytesize }
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Check if source is a git repo and if it's clean
|
|
243
|
+
if Dir.exist?(File.join(@source_path, ".git"))
|
|
244
|
+
unless force
|
|
245
|
+
check_source_clean!
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Create backup patch of current source state
|
|
249
|
+
backup_patch_path = File.join(@box_dir, "patches", "backup.patch")
|
|
250
|
+
FileUtils.mkdir_p(File.join(@box_dir, "patches"))
|
|
251
|
+
|
|
252
|
+
Dir.chdir(@source_path) do
|
|
253
|
+
backup = `git diff 2>/dev/null`
|
|
254
|
+
File.write(backup_patch_path, backup) unless backup.empty?
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Apply the patch
|
|
258
|
+
Dir.chdir(@source_path) do
|
|
259
|
+
IO.popen(["git", "apply", "-v"], "w") do |io|
|
|
260
|
+
io.write(patch_content)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
unless $?.success?
|
|
264
|
+
raise PatchApplyError, "Failed to apply patch. The source project may have conflicts."
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Show what changed
|
|
268
|
+
result_diff = `git diff 2>/dev/null`
|
|
269
|
+
result_diff
|
|
270
|
+
end
|
|
271
|
+
else
|
|
272
|
+
# Non-git source: apply patch with patch command
|
|
273
|
+
backup_patch_path = File.join(@box_dir, "patches", "backup.diff")
|
|
274
|
+
FileUtils.mkdir_p(File.join(@box_dir, "patches"))
|
|
275
|
+
File.write(backup_patch_path, "backup not available for non-git source")
|
|
276
|
+
|
|
277
|
+
Dir.chdir(@source_path) do
|
|
278
|
+
IO.popen(["patch", "-p1", "-N", "-r", "/dev/null"], "w") do |io|
|
|
279
|
+
io.write(patch_content)
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
"Patch applied to non-git source at #{@source_path}"
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def discard
|
|
288
|
+
raise Error, "Box directory does not exist" unless Dir.exist?(@box_dir)
|
|
289
|
+
|
|
290
|
+
# For git-worktree mode, remove the worktree first
|
|
291
|
+
if %w[git-worktree git_worktree].include?(@metadata.mode)
|
|
292
|
+
mode_instance.teardown
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
FileUtils.rm_rf(@box_dir)
|
|
296
|
+
@metadata.status = "discarded"
|
|
297
|
+
{ id: @id, status: "discarded" }
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def source_clean?
|
|
301
|
+
return true unless Dir.exist?(File.join(@source_path, ".git"))
|
|
302
|
+
Dir.chdir(@source_path) { `git status --porcelain 2>/dev/null`.strip.empty? }
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
private
|
|
306
|
+
|
|
307
|
+
attr_reader :box_dir, :metadata_path
|
|
308
|
+
|
|
309
|
+
def runner
|
|
310
|
+
@runner ||= Runner.new(
|
|
311
|
+
box_id: @id,
|
|
312
|
+
workspace_path: @workspace_path,
|
|
313
|
+
logs_dir: File.join(@box_dir, "logs")
|
|
314
|
+
)
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def mode_instance
|
|
318
|
+
current_mode = @mode.to_s.empty? ? @metadata.mode.to_s : @mode.to_s
|
|
319
|
+
|
|
320
|
+
case current_mode
|
|
321
|
+
when "copy"
|
|
322
|
+
Modes::CopyMode.new(
|
|
323
|
+
source_path: @source_path,
|
|
324
|
+
workspace_path: @workspace_path
|
|
325
|
+
)
|
|
326
|
+
when "git-worktree", "git_worktree"
|
|
327
|
+
Modes::GitWorktreeMode.new(
|
|
328
|
+
source_path: @source_path, workspace_path: @workspace_path, box_id: @id
|
|
329
|
+
)
|
|
330
|
+
else
|
|
331
|
+
raise InvalidModeError, "Unknown mode: #{current_mode}"
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def git_latest_commit
|
|
336
|
+
# In git-worktree mode the workspace's `.git` is a FILE (a gitdir
|
|
337
|
+
# pointer), not a directory — so Dir.exist? is false and this would
|
|
338
|
+
# wrongly return "none". File.exist? is true for both files and dirs.
|
|
339
|
+
return "none" unless File.exist?(File.join(@workspace_path, ".git"))
|
|
340
|
+
|
|
341
|
+
Dir.chdir(@workspace_path) do
|
|
342
|
+
`git rev-parse HEAD 2>/dev/null`.strip
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def source_git_commit
|
|
347
|
+
git_dir = File.join(@source_path, ".git")
|
|
348
|
+
return "none" unless Dir.exist?(git_dir)
|
|
349
|
+
|
|
350
|
+
Dir.chdir(@source_path) do
|
|
351
|
+
`git rev-parse HEAD 2>/dev/null`.strip
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def source_git_branch
|
|
356
|
+
git_dir = File.join(@source_path, ".git")
|
|
357
|
+
return "none" unless Dir.exist?(git_dir)
|
|
358
|
+
|
|
359
|
+
Dir.chdir(@source_path) do
|
|
360
|
+
`git rev-parse --abbrev-ref HEAD 2>/dev/null`.strip
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def resolve_checkpoint_commit(checkpoint_id)
|
|
365
|
+
cp = @metadata.checkpoints.detect { |c| c["id"] == checkpoint_id }
|
|
366
|
+
if cp
|
|
367
|
+
cp["git_commit"]
|
|
368
|
+
else
|
|
369
|
+
checkpoint_id
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def check_source_clean!
|
|
374
|
+
unless source_clean?
|
|
375
|
+
raise DirtySourceError,
|
|
376
|
+
"Source project has uncommitted changes.\n" \
|
|
377
|
+
"Use --force to apply anyway."
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
end
|