-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathgitmachine.go
More file actions
460 lines (391 loc) · 11.4 KB
/
gitmachine.go
File metadata and controls
460 lines (391 loc) · 11.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
package gitmachine
import (
"context"
"fmt"
"strings"
"sync"
"time"
)
const repoPath = "/home/user/repo"
// GitMachine wraps a Machine with git lifecycle management.
// It clones a repository on start, auto-commits on pause/stop,
// and persists sessions as git branches.
type GitMachine struct {
mu sync.Mutex
machine Machine
repository string
token string
identity GitIdentity
onStartCb LifecycleHook
onPauseCb LifecycleHook
onResumeCb LifecycleHook
onEndCb LifecycleHook
env map[string]string
session string
autoCommit bool
onEventCb func(event string, data map[string]interface{}, gm *GitMachine)
logs []LogEntry
skipAutoCommit bool
}
// NewGitMachine creates a new GitMachine with the given configuration.
func NewGitMachine(config GitMachineConfig) *GitMachine {
identity := GitIdentity{Name: "GitMachine", Email: "gitagent@machine"}
if config.Identity != nil {
identity = *config.Identity
}
autoCommit := true
if config.AutoCommit != nil {
autoCommit = *config.AutoCommit
}
env := make(map[string]string)
for k, v := range config.Env {
env[k] = v
}
return &GitMachine{
machine: config.Machine,
repository: config.Repository,
token: config.Token,
identity: identity,
onStartCb: config.OnStart,
onPauseCb: config.OnPause,
onResumeCb: config.OnResume,
onEndCb: config.OnEnd,
env: env,
session: config.Session,
autoCommit: autoCommit,
onEventCb: config.OnEvent,
}
}
// ConnectGitMachine reconnects to an already-running GitMachine by its machine.
// The sandbox must still be alive and the repo already cloned.
func ConnectGitMachine(machine Machine, config GitMachineConfig) (*GitMachine, error) {
config.Machine = machine
gm := NewGitMachine(config)
gm.emit("reconnected", map[string]interface{}{"id": machine.ID()})
return gm, nil
}
// ID returns the underlying machine's ID.
func (gm *GitMachine) ID() string {
return gm.machine.ID()
}
// State returns the underlying machine's state.
func (gm *GitMachine) State() MachineState {
return gm.machine.State()
}
// Path returns the repository path inside the VM.
func (gm *GitMachine) Path() string {
return repoPath
}
// --- Lifecycle ---
// Start initializes the machine, clones the repository, checks out the session
// branch (if configured), configures git identity, and invokes the OnStart hook.
func (gm *GitMachine) Start(ctx context.Context) error {
if err := gm.machine.Start(ctx); err != nil {
return fmt.Errorf("start machine: %w", err)
}
// Clone with token embedded in URL (stays inside sandbox).
authURL := gm.authURL()
if _, err := gm.machine.Execute(ctx, fmt.Sprintf("git clone %s %s", authURL, repoPath), nil); err != nil {
return fmt.Errorf("clone repo: %w", err)
}
// Checkout session branch if specified.
if gm.session != "" {
cmd := fmt.Sprintf("git checkout %s 2>/dev/null || git checkout -b %s", gm.session, gm.session)
if _, err := gm.exec(ctx, cmd); err != nil {
return fmt.Errorf("checkout session branch: %w", err)
}
}
// Configure git identity for commits.
if _, err := gm.exec(ctx, fmt.Sprintf(`git config user.name "%s"`, gm.identity.Name)); err != nil {
return fmt.Errorf("set git user.name: %w", err)
}
if _, err := gm.exec(ctx, fmt.Sprintf(`git config user.email "%s"`, gm.identity.Email)); err != nil {
return fmt.Errorf("set git user.email: %w", err)
}
gm.emit("started", map[string]interface{}{
"session": gm.session,
"repoPath": repoPath,
})
if gm.onStartCb != nil {
if err := gm.onStartCb(gm); err != nil {
return fmt.Errorf("onStart hook: %w", err)
}
}
return nil
}
// Pause auto-commits (if enabled), invokes the OnPause hook, and pauses the machine.
func (gm *GitMachine) Pause(ctx context.Context) error {
gm.mu.Lock()
skip := gm.skipAutoCommit
gm.mu.Unlock()
if gm.autoCommit && !skip {
gm.autoCommitChanges(ctx)
}
if gm.onPauseCb != nil {
if err := gm.onPauseCb(gm); err != nil {
return fmt.Errorf("onPause hook: %w", err)
}
}
if err := gm.machine.Pause(ctx); err != nil {
return fmt.Errorf("pause machine: %w", err)
}
gm.emit("paused", map[string]interface{}{})
return nil
}
// Resume restores a paused machine and invokes the OnResume hook.
func (gm *GitMachine) Resume(ctx context.Context) error {
if err := gm.machine.Resume(ctx); err != nil {
return fmt.Errorf("resume machine: %w", err)
}
if gm.onResumeCb != nil {
if err := gm.onResumeCb(gm); err != nil {
return fmt.Errorf("onResume hook: %w", err)
}
}
gm.emit("resumed", map[string]interface{}{})
return nil
}
// Stop auto-commits, pushes, invokes the OnEnd hook, and stops the machine.
func (gm *GitMachine) Stop(ctx context.Context) error {
if gm.autoCommit {
gm.autoCommitChanges(ctx)
gm.pushChanges(ctx)
}
gm.emit("stopping", map[string]interface{}{})
if gm.onEndCb != nil {
if err := gm.onEndCb(gm); err != nil {
return fmt.Errorf("onEnd hook: %w", err)
}
}
if err := gm.machine.Stop(ctx); err != nil {
return fmt.Errorf("stop machine: %w", err)
}
gm.emit("stopped", map[string]interface{}{})
return nil
}
// --- Git Operations ---
// Diff returns the git diff of all changes against HEAD.
// Uses the whileRunning pattern: if paused, transparently resumes and re-pauses.
func (gm *GitMachine) Diff(ctx context.Context) (string, error) {
var diff string
err := gm.whileRunning(ctx, func() error {
// Stage everything first so untracked files show in the diff.
if _, err := gm.exec(ctx, "git add -A"); err != nil {
return err
}
result, err := gm.exec(ctx, "git diff --cached")
if err != nil {
return err
}
// Unstage so we don't affect working state.
if _, err := gm.exec(ctx, "git reset HEAD --quiet"); err != nil {
return err
}
diff = result.Stdout
return nil
})
return diff, err
}
// Commit stages all changes and commits them. Returns the commit SHA, or empty
// string if there was nothing to commit. Uses the whileRunning pattern.
func (gm *GitMachine) Commit(ctx context.Context, message string) (string, error) {
var sha string
err := gm.whileRunning(ctx, func() error {
if _, err := gm.exec(ctx, "git add -A"); err != nil {
return err
}
// Check if there are staged changes.
check, err := gm.exec(ctx, "git diff --cached --quiet")
if err != nil {
return err
}
if check.ExitCode == 0 {
// Nothing to commit.
return nil
}
if message == "" {
message = "checkpoint"
}
if _, err := gm.exec(ctx, fmt.Sprintf(`git commit -m "%s"`, message)); err != nil {
return err
}
result, err := gm.exec(ctx, "git rev-parse HEAD")
if err != nil {
return err
}
sha = strings.TrimSpace(result.Stdout)
gm.emit("committed", map[string]interface{}{
"sha": sha,
"message": message,
})
return nil
})
return sha, err
}
// Push pushes changes to the remote origin. Uses the whileRunning pattern.
func (gm *GitMachine) Push(ctx context.Context) error {
return gm.whileRunning(ctx, func() error {
gm.pushChanges(ctx)
gm.emit("pushed", map[string]interface{}{})
return nil
})
}
// Pull pulls changes from the remote origin. Uses the whileRunning pattern.
func (gm *GitMachine) Pull(ctx context.Context) error {
return gm.whileRunning(ctx, func() error {
branch := gm.session
if branch == "" {
branch = "main"
}
if _, err := gm.exec(ctx, fmt.Sprintf("git pull origin %s", branch)); err != nil {
return err
}
gm.emit("pulled", map[string]interface{}{})
return nil
})
}
// Hash returns the current HEAD commit SHA. Uses the whileRunning pattern.
func (gm *GitMachine) Hash(ctx context.Context) (string, error) {
var hash string
err := gm.whileRunning(ctx, func() error {
result, err := gm.exec(ctx, "git rev-parse HEAD")
if err != nil {
return err
}
hash = strings.TrimSpace(result.Stdout)
return nil
})
return hash, err
}
// --- Runtime ---
// Update merges new environment variables and/or calls an update callback.
func (gm *GitMachine) Update(env map[string]string, onUpdate func(gm *GitMachine) error) error {
gm.mu.Lock()
for k, v := range env {
gm.env[k] = v
}
gm.mu.Unlock()
if onUpdate != nil {
return onUpdate(gm)
}
return nil
}
// Run executes a command in the sandbox with the configured environment.
// The command runs in the repo directory by default.
func (gm *GitMachine) Run(ctx context.Context, command string, opts *RunOptions) (*ExecutionResult, error) {
gm.mu.Lock()
mergedEnv := make(map[string]string)
for k, v := range gm.env {
mergedEnv[k] = v
}
gm.mu.Unlock()
cwd := repoPath
var timeout int
var onStdout, onStderr OnOutput
var onExit OnExit
if opts != nil {
if opts.Cwd != "" {
cwd = opts.Cwd
}
for k, v := range opts.Env {
mergedEnv[k] = v
}
timeout = opts.Timeout
onStdout = opts.OnStdout
onStderr = opts.OnStderr
onExit = opts.OnExit
}
result, err := gm.machine.Execute(ctx, command, &ExecuteOptions{
Cwd: cwd,
Env: mergedEnv,
Timeout: timeout,
OnStdout: onStdout,
OnStderr: onStderr,
})
if err != nil {
return nil, err
}
gm.mu.Lock()
gm.logs = append(gm.logs, LogEntry{
Command: command,
Result: *result,
Timestamp: time.Now(),
})
gm.mu.Unlock()
if onExit != nil {
onExit(result.ExitCode)
}
return result, nil
}
// Logs returns a copy of the command execution history.
func (gm *GitMachine) Logs() []LogEntry {
gm.mu.Lock()
defer gm.mu.Unlock()
out := make([]LogEntry, len(gm.logs))
copy(out, gm.logs)
return out
}
// --- Internal ---
// whileRunning transparently resumes a paused machine, runs fn, and re-pauses
// if the machine was paused before the call. This allows git operations to work
// even when the machine is paused.
func (gm *GitMachine) whileRunning(ctx context.Context, fn func() error) error {
wasPaused := gm.machine.State() == StatePaused
if wasPaused {
if err := gm.machine.Resume(ctx); err != nil {
return fmt.Errorf("resume for whileRunning: %w", err)
}
}
fnErr := fn()
if wasPaused {
gm.mu.Lock()
gm.skipAutoCommit = true
gm.mu.Unlock()
pauseErr := gm.machine.Pause(ctx)
gm.mu.Lock()
gm.skipAutoCommit = false
gm.mu.Unlock()
if fnErr != nil {
return fnErr
}
return pauseErr
}
return fnErr
}
// autoCommitChanges stages and commits all changes (best-effort).
func (gm *GitMachine) autoCommitChanges(ctx context.Context) {
_, _ = gm.exec(ctx, `git add -A && git diff --cached --quiet || git commit -m "auto: checkpoint"`)
}
// pushChanges pushes to the origin (best-effort).
func (gm *GitMachine) pushChanges(ctx context.Context) {
branch := gm.session
if branch == "" {
branch = "main"
}
_, _ = gm.exec(ctx, fmt.Sprintf("git push origin %s", branch))
}
// exec runs a command in the repo directory.
func (gm *GitMachine) exec(ctx context.Context, command string) (*ExecutionResult, error) {
return gm.machine.Execute(ctx, command, &ExecuteOptions{Cwd: repoPath})
}
// authURL inserts the token into the repository URL for authentication.
func (gm *GitMachine) authURL() string {
return strings.Replace(gm.repository, "https://", fmt.Sprintf("https://%s@", gm.token), 1)
}
// emit fires a lifecycle event to the onEvent callback. Errors are swallowed.
func (gm *GitMachine) emit(event string, data map[string]interface{}) {
if gm.onEventCb == nil {
return
}
func() {
defer func() {
// Event callbacks should never crash the machine.
recover() //nolint:errcheck
}()
gm.onEventCb(event, data, gm)
}()
}
// BoolPtr is a helper to create a *bool for use in GitMachineConfig.AutoCommit.
func BoolPtr(b bool) *bool {
return &b
}