Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name: CI

# Workflow does not consume any user-controlled input (issue/PR titles or bodies,
# commit messages, comments, head_ref, etc.). Only built-in GitHub contexts and
# matrix values are interpolated, so command-injection vectors are not in scope.

on:
push:
branches: [main]
pull_request:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
test:
name: rake (Ruby ${{ matrix.ruby }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
ruby: ["3.0", "3.1", "3.2", "3.3"]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ya-luotao I believe it's worth testing on 3.4 and maybe even 4.0 - I would maybe even remove 3.0

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You make a good point, I will do that.

steps:
- uses: actions/checkout@v4

- uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ matrix.ruby }}
bundler-cache: true

- name: Run rake (spec + rubocop)
run: bundle exec rake
1 change: 1 addition & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ AllCops:
- 'pkg/**/*'
- 'examples/**/*'
- 'vendor/**/*'
- '.github/**/*'

plugins:
- rubocop-rspec
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@

All notable changes to the E2B Ruby SDK will be documented in this file.

## [Unreleased]

### Fixed

- **`process.Process/Start` streaming RPC is no longer retried** under any condition. The previous policy aborted on retry only after the first event had been delivered; a transport blip before the first byte could still spawn a duplicate process (e.g. `git clone` failing with "destination already exists" because attempt #1 created the directory before the connection dropped). `BaseService#handle_streaming_rpc` now sets `max_retries: 0` whenever the path ends in `/Start`. ([#128b1bc](https://github.com/ya-luotao/e2b-ruby/commit/128b1bc))

### Internal

- **CI**: `bundle exec rake` (rspec + rubocop) now runs on every push/PR via GitHub Actions across Ruby 3.0/3.1/3.2/3.3.
- **Test coverage**: new `spec/e2b/services/git_spec.rb` locks down credential URL handling, status parsing, branch parsing, and auth-error mapping for `E2B::Services::Git`.
- **Tooling**: removed unused `vcr` development dependency from `Gemfile` (no cassettes; spec suite is WebMock-only).

## [0.3.4] - 2026-05-05

### Added
Expand Down
1 change: 0 additions & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,5 @@ gemspec
group :development, :test do
gem "rake", "~> 13.0"
gem "rspec", "~> 3.0"
gem "vcr", "~> 6.0"
gem "webmock", "~> 3.0"
end
164 changes: 164 additions & 0 deletions spec/e2b/services/git_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
# frozen_string_literal: true

require "spec_helper"

RSpec.describe E2B::Services::Git do
subject(:git) { described_class.new(commands: commands) }

let(:commands) { instance_double(E2B::Services::Commands) }

def process_result(stdout: "", stderr: "", exit_code: 0)
E2B::Models::ProcessResult.new(
stdout: stdout,
stderr: stderr,
exit_code: exit_code
)
end

describe "credential URL helpers (private)" do
it "embeds and strips credentials on a vanilla HTTPS URL" do
embedded = git.send(:with_credentials, "https://github.com/owner/repo.git", "alice", "s3cret")
expect(embedded).to eq("https://alice:s3cret@github.com/owner/repo.git")

stripped = git.send(:strip_credentials, embedded)
expect(stripped).to eq("https://github.com/owner/repo.git")
end

it "URL-encodes special characters in the username and password" do
embedded = git.send(:with_credentials,
"https://example.com/r.git",
"user@example.com",
"p@ss:w/rd")

# `@` -> %40, `:` -> %3A, `/` -> %2F. These must be encoded — otherwise
# the URI parser would mis-attribute the password's `@` as the userinfo
# boundary and the leak the rest of the password into the hostname slot.
expect(embedded).to include("user%40example.com:p%40ss%3Aw%2Frd@example.com")
end

it "returns the original string when URI cannot parse it (e.g. SSH form)" do
ssh_url = "git@github.com:owner/repo.git"
expect(git.send(:with_credentials, ssh_url, "alice", "x")).to eq(ssh_url)
expect(git.send(:strip_credentials, ssh_url)).to eq(ssh_url)
end

it "strips credentials that were already present in the URL" do
stripped = git.send(:strip_credentials, "https://carol:tok@example.com/r.git")
expect(stripped).to eq("https://example.com/r.git")
end
end

describe "#clone" do
it "passes the URL through verbatim when no credentials are given" do
expected_cmd = "git clone #{Shellwords.escape("https://github.com/o/r.git")}"
expect(commands).to receive(:run)
.with(expected_cmd, hash_including(envs: hash_including("GIT_TERMINAL_PROMPT" => "0")))
.and_return(process_result)

git.clone("https://github.com/o/r.git")
end

it "embeds credentials into the URL before running the clone" do
authed = "https://alice:tok@github.com/o/r.git"
expect(commands).to receive(:run)
.with("git clone #{Shellwords.escape(authed)}",
hash_including(envs: hash_including("GIT_TERMINAL_PROMPT" => "0")))
.and_return(process_result)

git.clone("https://github.com/o/r.git", username: "alice", password: "tok")
end

it "raises GitAuthError when stderr indicates an auth failure" do
allow(commands).to receive(:run).and_return(
process_result(exit_code: 128, stderr: "fatal: Authentication failed for 'https://...'")
)

expect { git.clone("https://github.com/o/r.git") }
.to raise_error(E2B::GitAuthError, /Authentication failed/)
end
end

describe "#status" do
it "parses branch info, ahead/behind counts, modified, and untracked entries" do
raw = <<~PORCELAIN
# branch.head main
# branch.upstream origin/main
# branch.ab +2 -1
1 .M N... 100644 100644 100644 deadbeef deadbeef README.md
? scratch.log
PORCELAIN

allow(commands).to receive(:run).and_return(process_result(stdout: raw))

status = git.status("/repo")

expect(status.current_branch).to eq("main")
expect(status.upstream).to eq("origin/main")
expect(status.ahead).to eq(2)
expect(status.behind).to eq(1)
expect(status.detached).to be(false)
expect(status.modified_count).to eq(1)
expect(status.untracked_count).to eq(1)
expect(status).not_to be_clean
end

it "marks HEAD as detached without setting current_branch" do
raw = "# branch.head (detached)\n"
allow(commands).to receive(:run).and_return(process_result(stdout: raw))

status = git.status("/repo")

expect(status.detached).to be(true)
expect(status.current_branch).to be_nil
expect(status).to be_clean
end
end

describe "#branches" do
# The parser routes any ref containing `/` to `remote`. That matches
# real `git branch -a --format=%(refname:short)` output (locals are
# bare names; remotes are `origin/...`) but means a local branch
# literally named `feature/x` would be mis-routed. Use `dev` here.
it "splits local and remote, marks current via the trailing `*`, and drops origin/HEAD" do
raw = <<~LIST
main *
dev
origin/main
origin/HEAD
LIST

allow(commands).to receive(:run).and_return(process_result(stdout: raw))

branches = git.branches("/repo")

expect(branches.current).to eq("main")
expect(branches.local).to contain_exactly("main", "dev")
expect(branches.remote).to contain_exactly("origin/main")
end
end

describe "#push" do
it "raises GitUpstreamError when stderr indicates a missing upstream branch" do
allow(commands).to receive(:run).and_return(
process_result(exit_code: 128, stderr: "fatal: The current branch foo has no upstream branch.")
)

expect { git.push("/repo", remote: "origin", branch: "foo", set_upstream: false) }
.to raise_error(E2B::GitUpstreamError, /upstream/)
end
end

describe "default git environment" do
it "always sets GIT_TERMINAL_PROMPT=0 to prevent envd hangs on credential prompts" do
received_envs = nil
allow(commands).to receive(:run) do |_cmd, envs:, **|
received_envs = envs
process_result
end

git.init("/repo")

expect(received_envs["GIT_TERMINAL_PROMPT"]).to eq("0")
end
end
end
Loading