e2b 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.
@@ -0,0 +1,893 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "shellwords"
5
+
6
+ module E2B
7
+ module Services
8
+ # Represents the status of a single file in the git working tree
9
+ #
10
+ # @attr_reader path [String] File path relative to the repository root
11
+ # @attr_reader index_status [String] Status in the index (staged area)
12
+ # @attr_reader work_tree_status [String] Status in the working tree
13
+ GitFileStatus = Struct.new(:path, :index_status, :work_tree_status, keyword_init: true)
14
+
15
+ # Represents the result of `git status`, including branch info and file statuses
16
+ #
17
+ # @example
18
+ # status = sandbox.git.status("/home/user/repo")
19
+ # puts status.current_branch
20
+ # puts "Clean!" if status.clean?
21
+ class GitStatus
22
+ # @return [String, nil] Name of the current branch, or nil if detached
23
+ attr_reader :current_branch
24
+
25
+ # @return [String, nil] Name of the upstream tracking branch
26
+ attr_reader :upstream
27
+
28
+ # @return [Integer] Number of commits ahead of upstream
29
+ attr_reader :ahead
30
+
31
+ # @return [Integer] Number of commits behind upstream
32
+ attr_reader :behind
33
+
34
+ # @return [Boolean] Whether HEAD is in detached state
35
+ attr_reader :detached
36
+
37
+ # @return [Array<GitFileStatus>] List of file statuses
38
+ attr_reader :file_status
39
+
40
+ # @param current_branch [String, nil] Current branch name
41
+ # @param upstream [String, nil] Upstream tracking branch
42
+ # @param ahead [Integer] Commits ahead of upstream
43
+ # @param behind [Integer] Commits behind upstream
44
+ # @param detached [Boolean] Whether HEAD is detached
45
+ # @param file_status [Array<GitFileStatus>] File status entries
46
+ def initialize(current_branch: nil, upstream: nil, ahead: 0, behind: 0, detached: false, file_status: [])
47
+ @current_branch = current_branch
48
+ @upstream = upstream
49
+ @ahead = ahead
50
+ @behind = behind
51
+ @detached = detached
52
+ @file_status = file_status
53
+ end
54
+
55
+ # Whether the working tree is clean (no changes at all)
56
+ #
57
+ # @return [Boolean]
58
+ def clean?
59
+ file_status.empty?
60
+ end
61
+
62
+ # Whether the working tree has any changes
63
+ #
64
+ # @return [Boolean]
65
+ def has_changes?
66
+ !clean?
67
+ end
68
+
69
+ # Whether there are any staged changes
70
+ #
71
+ # @return [Boolean]
72
+ def has_staged?
73
+ file_status.any? { |f| f.index_status != "." && f.index_status != "?" }
74
+ end
75
+
76
+ # Whether there are any untracked files
77
+ #
78
+ # @return [Boolean]
79
+ def has_untracked?
80
+ file_status.any? { |f| f.index_status == "?" }
81
+ end
82
+
83
+ # Whether there are any merge conflicts
84
+ #
85
+ # @return [Boolean]
86
+ def has_conflicts?
87
+ file_status.any? { |f| f.index_status == "u" || f.index_status == "U" }
88
+ end
89
+
90
+ # Number of staged files
91
+ #
92
+ # @return [Integer]
93
+ def staged_count
94
+ file_status.count { |f| f.index_status != "." && f.index_status != "?" }
95
+ end
96
+
97
+ # Number of untracked files
98
+ #
99
+ # @return [Integer]
100
+ def untracked_count
101
+ file_status.count { |f| f.index_status == "?" }
102
+ end
103
+
104
+ # Number of conflicted files
105
+ #
106
+ # @return [Integer]
107
+ def conflict_count
108
+ file_status.count { |f| f.index_status == "u" || f.index_status == "U" }
109
+ end
110
+
111
+ # Number of modified files (in the working tree)
112
+ #
113
+ # @return [Integer]
114
+ def modified_count
115
+ file_status.count { |f| f.work_tree_status == "M" }
116
+ end
117
+ end
118
+
119
+ # Represents the branches in a git repository
120
+ #
121
+ # @example
122
+ # branches = sandbox.git.branches("/home/user/repo")
123
+ # puts "Current: #{branches.current}"
124
+ # puts "Local: #{branches.local.join(', ')}"
125
+ class GitBranches
126
+ # @return [String, nil] The currently checked-out branch
127
+ attr_reader :current
128
+
129
+ # @return [Array<String>] List of local branch names
130
+ attr_reader :local
131
+
132
+ # @return [Array<String>] List of remote branch names
133
+ attr_reader :remote
134
+
135
+ # @param current [String, nil] Current branch name
136
+ # @param local [Array<String>] Local branch names
137
+ # @param remote [Array<String>] Remote branch names
138
+ def initialize(current: nil, local: [], remote: [])
139
+ @current = current
140
+ @local = local
141
+ @remote = remote
142
+ end
143
+ end
144
+
145
+ # Git operations service for E2B sandbox
146
+ #
147
+ # Runs git CLI commands inside the sandbox by delegating to the Commands service.
148
+ # Does not use RPC directly - all operations are performed via shell commands.
149
+ #
150
+ # @example
151
+ # sandbox.git.clone("https://github.com/user/repo.git", path: "/home/user/repo")
152
+ # sandbox.git.add("/home/user/repo")
153
+ # sandbox.git.commit("/home/user/repo", "Initial commit", author_name: "Bot", author_email: "bot@example.com")
154
+ # sandbox.git.push("/home/user/repo")
155
+ class Git
156
+ # Default environment variables applied to all git commands
157
+ DEFAULT_GIT_ENV = { "GIT_TERMINAL_PROMPT" => "0" }.freeze
158
+
159
+ # Default timeout for git operations in seconds
160
+ DEFAULT_TIMEOUT = 300
161
+
162
+ # Valid git config scopes
163
+ VALID_SCOPES = %w[global local system].freeze
164
+
165
+ # @param commands [E2B::Services::Commands] The commands service instance
166
+ def initialize(commands:)
167
+ @commands = commands
168
+ end
169
+
170
+ # Clone a git repository
171
+ #
172
+ # @param url [String] Repository URL to clone
173
+ # @param path [String, nil] Destination path; if nil, git chooses the directory name
174
+ # @param branch [String, nil] Branch to checkout after cloning
175
+ # @param depth [Integer, nil] Create a shallow clone with the given depth
176
+ # @param username [String, nil] Username for authentication
177
+ # @param password [String, nil] Password or token for authentication
178
+ # @param envs [Hash{String => String}, nil] Additional environment variables
179
+ # @param user [String, nil] User context (reserved for future use)
180
+ # @param cwd [String, nil] Working directory for the clone command
181
+ # @param timeout [Integer, nil] Command timeout in seconds
182
+ # @return [E2B::Models::ProcessResult] Command result
183
+ # @raise [E2B::GitAuthError] If authentication fails
184
+ def clone(url, path: nil, branch: nil, depth: nil, username: nil, password: nil,
185
+ envs: nil, user: nil, cwd: nil, timeout: nil)
186
+ clone_url = if username && password
187
+ with_credentials(url, username, password)
188
+ else
189
+ url
190
+ end
191
+
192
+ args = ["clone"]
193
+ args += ["--branch", branch] if branch
194
+ args += ["--depth", depth.to_s] if depth
195
+ args << Shellwords.escape(clone_url)
196
+ args << Shellwords.escape(path) if path
197
+
198
+ result = run_git(args, nil, envs: envs, user: user, cwd: cwd, timeout: timeout)
199
+ check_auth_failure!(result)
200
+ result
201
+ end
202
+
203
+ # Initialize a new git repository
204
+ #
205
+ # @param path [String] Path where the repository should be initialized
206
+ # @param bare [Boolean] Create a bare repository
207
+ # @param initial_branch [String, nil] Name for the initial branch
208
+ # @param envs [Hash{String => String}, nil] Additional environment variables
209
+ # @param user [String, nil] User context (reserved for future use)
210
+ # @param cwd [String, nil] Working directory
211
+ # @param timeout [Integer, nil] Command timeout in seconds
212
+ # @return [E2B::Models::ProcessResult] Command result
213
+ def init(path, bare: false, initial_branch: nil, envs: nil, user: nil, cwd: nil, timeout: nil)
214
+ args = ["init"]
215
+ args << "--bare" if bare
216
+ args += ["--initial-branch", initial_branch] if initial_branch
217
+ args << Shellwords.escape(path)
218
+
219
+ run_git(args, nil, envs: envs, user: user, cwd: cwd, timeout: timeout)
220
+ end
221
+
222
+ # Add a remote to a repository
223
+ #
224
+ # @param path [String] Repository path
225
+ # @param name [String] Remote name (e.g., "origin")
226
+ # @param url [String] Remote URL
227
+ # @param fetch [Boolean] Fetch from the remote after adding
228
+ # @param overwrite [Boolean] If true, set-url on an existing remote instead of failing
229
+ # @param envs [Hash{String => String}, nil] Additional environment variables
230
+ # @param user [String, nil] User context (reserved for future use)
231
+ # @param cwd [String, nil] Working directory
232
+ # @param timeout [Integer, nil] Command timeout in seconds
233
+ # @return [E2B::Models::ProcessResult] Command result
234
+ def remote_add(path, name, url, fetch: false, overwrite: false, envs: nil, user: nil, cwd: nil, timeout: nil)
235
+ if overwrite
236
+ # Check if remote already exists
237
+ existing = remote_get(path, name, envs: envs, user: user, cwd: cwd, timeout: timeout)
238
+ if existing
239
+ args = ["remote", "set-url", name, Shellwords.escape(url)]
240
+ result = run_git(args, path, envs: envs, user: user, cwd: cwd, timeout: timeout)
241
+ else
242
+ args = ["remote", "add", name, Shellwords.escape(url)]
243
+ result = run_git(args, path, envs: envs, user: user, cwd: cwd, timeout: timeout)
244
+ end
245
+ else
246
+ args = ["remote", "add", name, Shellwords.escape(url)]
247
+ result = run_git(args, path, envs: envs, user: user, cwd: cwd, timeout: timeout)
248
+ end
249
+
250
+ if fetch
251
+ fetch_args = ["fetch", name]
252
+ run_git(fetch_args, path, envs: envs, user: user, cwd: cwd, timeout: timeout)
253
+ end
254
+
255
+ result
256
+ end
257
+
258
+ # Get the URL of a named remote
259
+ #
260
+ # @param path [String] Repository path
261
+ # @param name [String] Remote name (e.g., "origin")
262
+ # @param envs [Hash{String => String}, nil] Additional environment variables
263
+ # @param user [String, nil] User context (reserved for future use)
264
+ # @param cwd [String, nil] Working directory
265
+ # @param timeout [Integer, nil] Command timeout in seconds
266
+ # @return [String, nil] Remote URL, or nil if the remote does not exist
267
+ def remote_get(path, name, envs: nil, user: nil, cwd: nil, timeout: nil)
268
+ args = ["remote", "get-url", name]
269
+ result = run_git(args, path, envs: envs, user: user, cwd: cwd, timeout: timeout)
270
+ return nil unless result.success?
271
+
272
+ url = result.stdout.strip
273
+ url.empty? ? nil : url
274
+ end
275
+
276
+ # Get the status of a git repository
277
+ #
278
+ # @param path [String] Repository path
279
+ # @param envs [Hash{String => String}, nil] Additional environment variables
280
+ # @param user [String, nil] User context (reserved for future use)
281
+ # @param cwd [String, nil] Working directory
282
+ # @param timeout [Integer, nil] Command timeout in seconds
283
+ # @return [GitStatus] Parsed status information
284
+ def status(path, envs: nil, user: nil, cwd: nil, timeout: nil)
285
+ args = ["status", "--porcelain=v2", "--branch"]
286
+ result = run_git(args, path, envs: envs, user: user, cwd: cwd, timeout: timeout)
287
+ parse_status(result.stdout)
288
+ end
289
+
290
+ # List all branches (local and remote)
291
+ #
292
+ # @param path [String] Repository path
293
+ # @param envs [Hash{String => String}, nil] Additional environment variables
294
+ # @param user [String, nil] User context (reserved for future use)
295
+ # @param cwd [String, nil] Working directory
296
+ # @param timeout [Integer, nil] Command timeout in seconds
297
+ # @return [GitBranches] Parsed branch information
298
+ def branches(path, envs: nil, user: nil, cwd: nil, timeout: nil)
299
+ args = ["branch", "-a", "--format=%(refname:short) %(HEAD)"]
300
+ result = run_git(args, path, envs: envs, user: user, cwd: cwd, timeout: timeout)
301
+ parse_branches(result.stdout)
302
+ end
303
+
304
+ # Create a new branch
305
+ #
306
+ # @param path [String] Repository path
307
+ # @param branch [String] Branch name to create
308
+ # @param envs [Hash{String => String}, nil] Additional environment variables
309
+ # @param user [String, nil] User context (reserved for future use)
310
+ # @param cwd [String, nil] Working directory
311
+ # @param timeout [Integer, nil] Command timeout in seconds
312
+ # @return [E2B::Models::ProcessResult] Command result
313
+ def create_branch(path, branch, envs: nil, user: nil, cwd: nil, timeout: nil)
314
+ args = ["branch", branch]
315
+ run_git(args, path, envs: envs, user: user, cwd: cwd, timeout: timeout)
316
+ end
317
+
318
+ # Checkout an existing branch
319
+ #
320
+ # @param path [String] Repository path
321
+ # @param branch [String] Branch name to checkout
322
+ # @param envs [Hash{String => String}, nil] Additional environment variables
323
+ # @param user [String, nil] User context (reserved for future use)
324
+ # @param cwd [String, nil] Working directory
325
+ # @param timeout [Integer, nil] Command timeout in seconds
326
+ # @return [E2B::Models::ProcessResult] Command result
327
+ def checkout_branch(path, branch, envs: nil, user: nil, cwd: nil, timeout: nil)
328
+ args = ["checkout", branch]
329
+ run_git(args, path, envs: envs, user: user, cwd: cwd, timeout: timeout)
330
+ end
331
+
332
+ # Delete a branch
333
+ #
334
+ # @param path [String] Repository path
335
+ # @param branch [String] Branch name to delete
336
+ # @param force [Boolean] Force-delete the branch (even if not fully merged)
337
+ # @param envs [Hash{String => String}, nil] Additional environment variables
338
+ # @param user [String, nil] User context (reserved for future use)
339
+ # @param cwd [String, nil] Working directory
340
+ # @param timeout [Integer, nil] Command timeout in seconds
341
+ # @return [E2B::Models::ProcessResult] Command result
342
+ def delete_branch(path, branch, force: false, envs: nil, user: nil, cwd: nil, timeout: nil)
343
+ flag = force ? "-D" : "-d"
344
+ args = ["branch", flag, branch]
345
+ run_git(args, path, envs: envs, user: user, cwd: cwd, timeout: timeout)
346
+ end
347
+
348
+ # Stage files for commit
349
+ #
350
+ # @param path [String] Repository path
351
+ # @param files [Array<String>, nil] Specific files to stage; ignored when +all+ is true
352
+ # @param all [Boolean] Stage all changes (tracked and untracked)
353
+ # @param envs [Hash{String => String}, nil] Additional environment variables
354
+ # @param user [String, nil] User context (reserved for future use)
355
+ # @param cwd [String, nil] Working directory
356
+ # @param timeout [Integer, nil] Command timeout in seconds
357
+ # @return [E2B::Models::ProcessResult] Command result
358
+ def add(path, files: nil, all: true, envs: nil, user: nil, cwd: nil, timeout: nil)
359
+ args = ["add"]
360
+ if all
361
+ args << "-A"
362
+ elsif files && !files.empty?
363
+ files.each { |f| args << Shellwords.escape(f) }
364
+ else
365
+ args << "-A"
366
+ end
367
+
368
+ run_git(args, path, envs: envs, user: user, cwd: cwd, timeout: timeout)
369
+ end
370
+
371
+ # Create a commit
372
+ #
373
+ # @param path [String] Repository path
374
+ # @param message [String] Commit message
375
+ # @param author_name [String, nil] Author name (overrides git config)
376
+ # @param author_email [String, nil] Author email (overrides git config)
377
+ # @param allow_empty [Boolean] Allow creating a commit with no changes
378
+ # @param envs [Hash{String => String}, nil] Additional environment variables
379
+ # @param user [String, nil] User context (reserved for future use)
380
+ # @param cwd [String, nil] Working directory
381
+ # @param timeout [Integer, nil] Command timeout in seconds
382
+ # @return [E2B::Models::ProcessResult] Command result
383
+ def commit(path, message, author_name: nil, author_email: nil, allow_empty: false,
384
+ envs: nil, user: nil, cwd: nil, timeout: nil)
385
+ args = ["commit", "-m", Shellwords.escape(message)]
386
+ args << "--allow-empty" if allow_empty
387
+
388
+ commit_envs = (envs || {}).dup
389
+ if author_name && author_email
390
+ commit_envs["GIT_AUTHOR_NAME"] = author_name
391
+ commit_envs["GIT_COMMITTER_NAME"] = author_name
392
+ commit_envs["GIT_AUTHOR_EMAIL"] = author_email
393
+ commit_envs["GIT_COMMITTER_EMAIL"] = author_email
394
+ end
395
+
396
+ run_git(args, path, envs: commit_envs, user: user, cwd: cwd, timeout: timeout)
397
+ end
398
+
399
+ # Reset the current HEAD to a specified state
400
+ #
401
+ # @param path [String] Repository path
402
+ # @param mode [String, nil] Reset mode: "soft", "mixed", "hard", "merge", or "keep"
403
+ # @param target [String, nil] Commit, branch, or tag to reset to
404
+ # @param paths [Array<String>, nil] Specific paths to reset (unstage)
405
+ # @param envs [Hash{String => String}, nil] Additional environment variables
406
+ # @param user [String, nil] User context (reserved for future use)
407
+ # @param cwd [String, nil] Working directory
408
+ # @param timeout [Integer, nil] Command timeout in seconds
409
+ # @return [E2B::Models::ProcessResult] Command result
410
+ def reset(path, mode: nil, target: nil, paths: nil, envs: nil, user: nil, cwd: nil, timeout: nil)
411
+ args = ["reset"]
412
+ args << "--#{mode}" if mode
413
+ args << target if target
414
+
415
+ if paths && !paths.empty?
416
+ args << "--"
417
+ paths.each { |p| args << Shellwords.escape(p) }
418
+ end
419
+
420
+ run_git(args, path, envs: envs, user: user, cwd: cwd, timeout: timeout)
421
+ end
422
+
423
+ # Restore working tree files
424
+ #
425
+ # @param path [String] Repository path
426
+ # @param paths [Array<String>] Paths to restore
427
+ # @param staged [Boolean, nil] Restore staged changes (--staged)
428
+ # @param worktree [Boolean, nil] Restore working tree changes (--worktree)
429
+ # @param source [String, nil] Restore from a specific source (commit, branch, etc.)
430
+ # @param envs [Hash{String => String}, nil] Additional environment variables
431
+ # @param user [String, nil] User context (reserved for future use)
432
+ # @param cwd [String, nil] Working directory
433
+ # @param timeout [Integer, nil] Command timeout in seconds
434
+ # @return [E2B::Models::ProcessResult] Command result
435
+ def restore(path, paths, staged: nil, worktree: nil, source: nil, envs: nil, user: nil, cwd: nil, timeout: nil)
436
+ args = ["restore"]
437
+ args << "--staged" if staged
438
+ args << "--worktree" if worktree
439
+ args += ["--source", source] if source
440
+
441
+ if paths.is_a?(Array)
442
+ paths.each { |p| args << Shellwords.escape(p) }
443
+ else
444
+ args << Shellwords.escape(paths)
445
+ end
446
+
447
+ run_git(args, path, envs: envs, user: user, cwd: cwd, timeout: timeout)
448
+ end
449
+
450
+ # Push commits to a remote repository
451
+ #
452
+ # When +username+ and +password+ are provided, the remote URL is temporarily
453
+ # modified to include credentials, then restored after the push completes.
454
+ #
455
+ # @param path [String] Repository path
456
+ # @param remote [String, nil] Remote name (defaults to "origin" by git)
457
+ # @param branch [String, nil] Branch to push
458
+ # @param set_upstream [Boolean] Set the upstream tracking branch (-u)
459
+ # @param username [String, nil] Username for authentication
460
+ # @param password [String, nil] Password or token for authentication
461
+ # @param envs [Hash{String => String}, nil] Additional environment variables
462
+ # @param user [String, nil] User context (reserved for future use)
463
+ # @param cwd [String, nil] Working directory
464
+ # @param timeout [Integer, nil] Command timeout in seconds
465
+ # @return [E2B::Models::ProcessResult] Command result
466
+ # @raise [E2B::GitAuthError] If authentication fails
467
+ # @raise [E2B::GitUpstreamError] If no upstream is configured and cannot be determined
468
+ def push(path, remote: nil, branch: nil, set_upstream: true, username: nil, password: nil,
469
+ envs: nil, user: nil, cwd: nil, timeout: nil)
470
+ effective_remote = remote || "origin"
471
+
472
+ if username && password
473
+ with_authenticated_remote(path, effective_remote, username, password,
474
+ envs: envs, user: user, cwd: cwd, timeout: timeout) do
475
+ do_push(path, effective_remote, branch, set_upstream,
476
+ envs: envs, user: user, cwd: cwd, timeout: timeout)
477
+ end
478
+ else
479
+ do_push(path, effective_remote, branch, set_upstream,
480
+ envs: envs, user: user, cwd: cwd, timeout: timeout)
481
+ end
482
+ end
483
+
484
+ # Pull changes from a remote repository
485
+ #
486
+ # When +username+ and +password+ are provided, the remote URL is temporarily
487
+ # modified to include credentials, then restored after the pull completes.
488
+ #
489
+ # @param path [String] Repository path
490
+ # @param remote [String, nil] Remote name (defaults to "origin" by git)
491
+ # @param branch [String, nil] Branch to pull
492
+ # @param username [String, nil] Username for authentication
493
+ # @param password [String, nil] Password or token for authentication
494
+ # @param envs [Hash{String => String}, nil] Additional environment variables
495
+ # @param user [String, nil] User context (reserved for future use)
496
+ # @param cwd [String, nil] Working directory
497
+ # @param timeout [Integer, nil] Command timeout in seconds
498
+ # @return [E2B::Models::ProcessResult] Command result
499
+ # @raise [E2B::GitAuthError] If authentication fails
500
+ def pull(path, remote: nil, branch: nil, username: nil, password: nil,
501
+ envs: nil, user: nil, cwd: nil, timeout: nil)
502
+ effective_remote = remote || "origin"
503
+
504
+ if username && password
505
+ with_authenticated_remote(path, effective_remote, username, password,
506
+ envs: envs, user: user, cwd: cwd, timeout: timeout) do
507
+ do_pull(path, effective_remote, branch,
508
+ envs: envs, user: user, cwd: cwd, timeout: timeout)
509
+ end
510
+ else
511
+ do_pull(path, effective_remote, branch,
512
+ envs: envs, user: user, cwd: cwd, timeout: timeout)
513
+ end
514
+ end
515
+
516
+ # Set a git configuration value
517
+ #
518
+ # @param key [String] Configuration key (e.g., "user.name")
519
+ # @param value [String] Configuration value
520
+ # @param scope [String] Config scope: "global", "local", or "system"
521
+ # @param path [String, nil] Repository path (required for "local" scope)
522
+ # @param envs [Hash{String => String}, nil] Additional environment variables
523
+ # @param user [String, nil] User context (reserved for future use)
524
+ # @param cwd [String, nil] Working directory
525
+ # @param timeout [Integer, nil] Command timeout in seconds
526
+ # @return [E2B::Models::ProcessResult] Command result
527
+ # @raise [E2B::E2BError] If the scope is invalid
528
+ def set_config(key, value, scope: "global", path: nil, envs: nil, user: nil, cwd: nil, timeout: nil)
529
+ validate_scope!(scope)
530
+
531
+ args = ["config", scope_flag(scope), key, Shellwords.escape(value)]
532
+ run_git(args, path, envs: envs, user: user, cwd: cwd, timeout: timeout)
533
+ end
534
+
535
+ # Get a git configuration value
536
+ #
537
+ # @param key [String] Configuration key (e.g., "user.name")
538
+ # @param scope [String] Config scope: "global", "local", or "system"
539
+ # @param path [String, nil] Repository path (required for "local" scope)
540
+ # @param envs [Hash{String => String}, nil] Additional environment variables
541
+ # @param user [String, nil] User context (reserved for future use)
542
+ # @param cwd [String, nil] Working directory
543
+ # @param timeout [Integer, nil] Command timeout in seconds
544
+ # @return [String, nil] Configuration value, or nil if not set
545
+ def get_config(key, scope: "global", path: nil, envs: nil, user: nil, cwd: nil, timeout: nil)
546
+ validate_scope!(scope)
547
+
548
+ args = ["config", scope_flag(scope), "--get", key]
549
+ result = run_git(args, path, envs: envs, user: user, cwd: cwd, timeout: timeout)
550
+ return nil unless result.success?
551
+
552
+ value = result.stdout.strip
553
+ value.empty? ? nil : value
554
+ end
555
+
556
+ # Configure a credential helper that accepts any credentials without prompting
557
+ #
558
+ # WARNING: This stores credentials in plaintext in the git credential store.
559
+ # Use only in sandbox environments where security of stored credentials is acceptable.
560
+ #
561
+ # @param username [String] Username for authentication
562
+ # @param password [String] Password or token for authentication
563
+ # @param host [String] Host to authenticate against (default: "github.com")
564
+ # @param protocol [String] Protocol to use (default: "https")
565
+ # @param envs [Hash{String => String}, nil] Additional environment variables
566
+ # @param user [String, nil] User context (reserved for future use)
567
+ # @param cwd [String, nil] Working directory
568
+ # @param timeout [Integer, nil] Command timeout in seconds
569
+ # @return [E2B::Models::ProcessResult] Command result
570
+ def dangerously_authenticate(username, password, host: "github.com", protocol: "https",
571
+ envs: nil, user: nil, cwd: nil, timeout: nil)
572
+ # Configure credential helper to use the store
573
+ set_config("credential.helper", "store", scope: "global",
574
+ envs: envs, user: user, cwd: cwd, timeout: timeout)
575
+
576
+ # Write credentials to the credential store via git credential approve
577
+ credential_input = [
578
+ "protocol=#{protocol}",
579
+ "host=#{host}",
580
+ "username=#{username}",
581
+ "password=#{password}",
582
+ ""
583
+ ].join("\n")
584
+
585
+ escaped_input = Shellwords.escape(credential_input)
586
+ args = ["credential", "approve"]
587
+ cmd = build_git_command(args, nil)
588
+ full_cmd = "echo #{escaped_input} | #{cmd}"
589
+
590
+ merged_envs = DEFAULT_GIT_ENV.merge(envs || {})
591
+ @commands.run(full_cmd, cwd: cwd, envs: merged_envs, timeout: timeout || DEFAULT_TIMEOUT)
592
+ end
593
+
594
+ # Configure the git user name and email
595
+ #
596
+ # @param name [String] User name for commits
597
+ # @param email [String] User email for commits
598
+ # @param scope [String] Config scope: "global", "local", or "system"
599
+ # @param path [String, nil] Repository path (required for "local" scope)
600
+ # @param envs [Hash{String => String}, nil] Additional environment variables
601
+ # @param user [String, nil] User context (reserved for future use)
602
+ # @param cwd [String, nil] Working directory
603
+ # @param timeout [Integer, nil] Command timeout in seconds
604
+ # @return [void]
605
+ def configure_user(name, email, scope: "global", path: nil, envs: nil, user: nil, cwd: nil, timeout: nil)
606
+ set_config("user.name", name, scope: scope, path: path,
607
+ envs: envs, user: user, cwd: cwd, timeout: timeout)
608
+ set_config("user.email", email, scope: scope, path: path,
609
+ envs: envs, user: user, cwd: cwd, timeout: timeout)
610
+ end
611
+
612
+ private
613
+
614
+ # Run a git command through the Commands service
615
+ #
616
+ # @param args [Array<String>] Git subcommand and arguments
617
+ # @param repo_path [String, nil] Repository path to pass via -C
618
+ # @param envs [Hash{String => String}, nil] Additional environment variables
619
+ # @param user [String, nil] User context (reserved for future use)
620
+ # @param cwd [String, nil] Working directory
621
+ # @param timeout [Integer, nil] Command timeout in seconds
622
+ # @return [E2B::Models::ProcessResult] Command result
623
+ def run_git(args, repo_path = nil, envs: nil, user: nil, cwd: nil, timeout: nil)
624
+ cmd = build_git_command(args, repo_path)
625
+ merged_envs = DEFAULT_GIT_ENV.merge(envs || {})
626
+ @commands.run(cmd, cwd: cwd, envs: merged_envs, timeout: timeout || DEFAULT_TIMEOUT)
627
+ end
628
+
629
+ # Build a complete git command string
630
+ #
631
+ # @param args [Array<String>] Git subcommand and arguments
632
+ # @param repo_path [String, nil] Repository path to pass via -C
633
+ # @return [String] Complete command string
634
+ def build_git_command(args, repo_path)
635
+ parts = ["git"]
636
+ parts += ["-C", Shellwords.escape(repo_path)] if repo_path
637
+ parts += args
638
+ parts.join(" ")
639
+ end
640
+
641
+ # Execute a push operation
642
+ #
643
+ # @param path [String] Repository path
644
+ # @param remote [String] Remote name
645
+ # @param branch [String, nil] Branch to push
646
+ # @param set_upstream [Boolean] Whether to set the upstream tracking branch
647
+ # @param envs [Hash{String => String}, nil] Additional environment variables
648
+ # @param user [String, nil] User context
649
+ # @param cwd [String, nil] Working directory
650
+ # @param timeout [Integer, nil] Command timeout in seconds
651
+ # @return [E2B::Models::ProcessResult] Command result
652
+ def do_push(path, remote, branch, set_upstream, envs: nil, user: nil, cwd: nil, timeout: nil)
653
+ args = ["push"]
654
+ args << "-u" if set_upstream
655
+ args << remote
656
+ args << branch if branch
657
+
658
+ result = run_git(args, path, envs: envs, user: user, cwd: cwd, timeout: timeout)
659
+ check_auth_failure!(result)
660
+ check_upstream_failure!(result)
661
+ result
662
+ end
663
+
664
+ # Execute a pull operation
665
+ #
666
+ # @param path [String] Repository path
667
+ # @param remote [String] Remote name
668
+ # @param branch [String, nil] Branch to pull
669
+ # @param envs [Hash{String => String}, nil] Additional environment variables
670
+ # @param user [String, nil] User context
671
+ # @param cwd [String, nil] Working directory
672
+ # @param timeout [Integer, nil] Command timeout in seconds
673
+ # @return [E2B::Models::ProcessResult] Command result
674
+ def do_pull(path, remote, branch, envs: nil, user: nil, cwd: nil, timeout: nil)
675
+ args = ["pull"]
676
+ args << remote
677
+ args << branch if branch
678
+
679
+ result = run_git(args, path, envs: envs, user: user, cwd: cwd, timeout: timeout)
680
+ check_auth_failure!(result)
681
+ result
682
+ end
683
+
684
+ # Temporarily set credentials on a remote URL, execute a block, then restore the original URL
685
+ #
686
+ # @param path [String] Repository path
687
+ # @param remote [String] Remote name
688
+ # @param username [String] Username for authentication
689
+ # @param password [String] Password or token for authentication
690
+ # @param envs [Hash{String => String}, nil] Additional environment variables
691
+ # @param user [String, nil] User context
692
+ # @param cwd [String, nil] Working directory
693
+ # @param timeout [Integer, nil] Command timeout in seconds
694
+ # @yield Block to execute with credentials set on the remote URL
695
+ # @return [Object] Result of the block
696
+ def with_authenticated_remote(path, remote, username, password, envs: nil, user: nil, cwd: nil, timeout: nil)
697
+ # Get current remote URL
698
+ original_url = remote_get(path, remote, envs: envs, user: user, cwd: cwd, timeout: timeout)
699
+ raise E2B::E2BError, "Remote '#{remote}' not found in repository" unless original_url
700
+
701
+ # Set authenticated URL
702
+ authenticated_url = with_credentials(original_url, username, password)
703
+ set_url_args = ["remote", "set-url", remote, Shellwords.escape(authenticated_url)]
704
+ run_git(set_url_args, path, envs: envs, user: user, cwd: cwd, timeout: timeout)
705
+
706
+ begin
707
+ yield
708
+ ensure
709
+ # Restore original URL (without credentials)
710
+ clean_url = strip_credentials(original_url)
711
+ restore_args = ["remote", "set-url", remote, Shellwords.escape(clean_url)]
712
+ run_git(restore_args, path, envs: envs, user: user, cwd: cwd, timeout: timeout)
713
+ end
714
+ end
715
+
716
+ # Embed credentials into a URL
717
+ #
718
+ # @param url [String] Original URL
719
+ # @param username [String] Username
720
+ # @param password [String] Password or token
721
+ # @return [String] URL with embedded credentials
722
+ def with_credentials(url, username, password)
723
+ uri = URI.parse(url)
724
+ uri.user = URI.encode_www_form_component(username)
725
+ uri.password = URI.encode_www_form_component(password)
726
+ uri.to_s
727
+ rescue URI::InvalidURIError
728
+ # Fallback for URLs that URI cannot parse (e.g., git@ SSH URLs)
729
+ url
730
+ end
731
+
732
+ # Remove credentials from a URL
733
+ #
734
+ # @param url [String] URL potentially containing credentials
735
+ # @return [String] URL without credentials
736
+ def strip_credentials(url)
737
+ uri = URI.parse(url)
738
+ uri.user = nil
739
+ uri.password = nil
740
+ uri.to_s
741
+ rescue URI::InvalidURIError
742
+ url
743
+ end
744
+
745
+ # Check if a command result indicates an authentication failure
746
+ #
747
+ # @param result [E2B::Models::ProcessResult] Command result to check
748
+ # @raise [E2B::GitAuthError] If authentication failure is detected
749
+ def check_auth_failure!(result)
750
+ return if result.success?
751
+
752
+ stderr = result.stderr.to_s
753
+ if stderr.include?("Authentication failed") ||
754
+ stderr.include?("could not read Username") ||
755
+ stderr.include?("terminal prompts disabled") ||
756
+ stderr.match?(/fatal:.*403/) ||
757
+ stderr.include?("Invalid username or password")
758
+ raise E2B::GitAuthError, "Git authentication failed: #{stderr.strip}"
759
+ end
760
+ end
761
+
762
+ # Check if a command result indicates an upstream configuration failure
763
+ #
764
+ # @param result [E2B::Models::ProcessResult] Command result to check
765
+ # @raise [E2B::GitUpstreamError] If upstream failure is detected
766
+ def check_upstream_failure!(result)
767
+ return if result.success?
768
+
769
+ stderr = result.stderr.to_s
770
+ if stderr.include?("has no upstream branch") ||
771
+ stderr.include?("no upstream configured") ||
772
+ stderr.include?("does not appear to be a git repository")
773
+ raise E2B::GitUpstreamError, "Git upstream error: #{stderr.strip}"
774
+ end
775
+ end
776
+
777
+ # Validate that a scope string is one of the accepted values
778
+ #
779
+ # @param scope [String] Scope to validate
780
+ # @raise [E2B::E2BError] If the scope is not valid
781
+ def validate_scope!(scope)
782
+ return if VALID_SCOPES.include?(scope)
783
+
784
+ raise E2B::E2BError, "Invalid git config scope '#{scope}'. Must be one of: #{VALID_SCOPES.join(', ')}"
785
+ end
786
+
787
+ # Convert a scope name to its git CLI flag
788
+ #
789
+ # @param scope [String] Scope name ("global", "local", or "system")
790
+ # @return [String] Corresponding git flag
791
+ def scope_flag(scope)
792
+ "--#{scope}"
793
+ end
794
+
795
+ # Parse the output of `git status --porcelain=v2 --branch`
796
+ #
797
+ # @param output [String] Raw stdout from git status
798
+ # @return [GitStatus] Parsed status object
799
+ def parse_status(output)
800
+ current_branch = nil
801
+ upstream = nil
802
+ ahead = 0
803
+ behind = 0
804
+ detached = false
805
+ file_status = []
806
+
807
+ output.each_line do |line|
808
+ line = line.chomp
809
+
810
+ case line
811
+ when /\A# branch\.head (.+)\z/
812
+ head = Regexp.last_match(1)
813
+ if head == "(detached)"
814
+ detached = true
815
+ else
816
+ current_branch = head
817
+ end
818
+ when /\A# branch\.upstream (.+)\z/
819
+ upstream = Regexp.last_match(1)
820
+ when /\A# branch\.ab \+(\d+) -(\d+)\z/
821
+ ahead = Regexp.last_match(1).to_i
822
+ behind = Regexp.last_match(2).to_i
823
+ when /\A1 (.)(.) .+ .+ .+ .+ .+ (.+)\z/
824
+ # Ordinary changed entry
825
+ idx = Regexp.last_match(1)
826
+ wt = Regexp.last_match(2)
827
+ filepath = Regexp.last_match(3)
828
+ file_status << GitFileStatus.new(path: filepath, index_status: idx, work_tree_status: wt)
829
+ when /\A2 (.)(.) .+ .+ .+ .+ .+ .+ (.+)\z/
830
+ # Renamed or copied entry (path includes original\ttab\tnew)
831
+ idx = Regexp.last_match(1)
832
+ wt = Regexp.last_match(2)
833
+ filepath = Regexp.last_match(3)
834
+ # The path field for renames is "new_path\told_path"; use the new path
835
+ file_status << GitFileStatus.new(path: filepath.split("\t").first, index_status: idx, work_tree_status: wt)
836
+ when /\Au (.)(.) .+ .+ .+ .+ .+ (.+)\z/
837
+ # Unmerged entry
838
+ idx = Regexp.last_match(1)
839
+ wt = Regexp.last_match(2)
840
+ filepath = Regexp.last_match(3)
841
+ file_status << GitFileStatus.new(path: filepath, index_status: "u", work_tree_status: wt)
842
+ when /\A\? (.+)\z/
843
+ # Untracked file
844
+ filepath = Regexp.last_match(1)
845
+ file_status << GitFileStatus.new(path: filepath, index_status: "?", work_tree_status: "?")
846
+ end
847
+ end
848
+
849
+ GitStatus.new(
850
+ current_branch: current_branch,
851
+ upstream: upstream,
852
+ ahead: ahead,
853
+ behind: behind,
854
+ detached: detached,
855
+ file_status: file_status
856
+ )
857
+ end
858
+
859
+ # Parse the output of `git branch -a --format="%(refname:short) %(HEAD)"`
860
+ #
861
+ # @param output [String] Raw stdout from git branch
862
+ # @return [GitBranches] Parsed branch information
863
+ def parse_branches(output)
864
+ current = nil
865
+ local = []
866
+ remote = []
867
+
868
+ output.each_line do |line|
869
+ line = line.chomp.strip
870
+ next if line.empty?
871
+
872
+ # Format: "branch_name *" for current, "branch_name " for others
873
+ # Remote branches appear as "origin/branch_name"
874
+ if line.end_with?("*")
875
+ branch_name = line.sub(/\s*\*\s*\z/, "").strip
876
+ current = branch_name
877
+ local << branch_name unless branch_name.include?("/")
878
+ else
879
+ branch_name = line.strip
880
+ if branch_name.include?("/")
881
+ # Skip HEAD pointer entries like "origin/HEAD"
882
+ remote << branch_name unless branch_name.end_with?("/HEAD")
883
+ else
884
+ local << branch_name
885
+ end
886
+ end
887
+ end
888
+
889
+ GitBranches.new(current: current, local: local, remote: remote)
890
+ end
891
+ end
892
+ end
893
+ end