diff --git a/spec/e2b/api/http_client_spec.rb b/spec/e2b/api/http_client_spec.rb index 34bb817..0f16777 100644 --- a/spec/e2b/api/http_client_spec.rb +++ b/spec/e2b/api/http_client_spec.rb @@ -117,6 +117,44 @@ end end + describe "#post" do + it "sends a JSON body and parses the JSON response" do + stub_request(:post, "#{base_url}/sandboxes") + .with(body: { templateID: "base" }.to_json) + .to_return( + status: 201, + body: '{"sandboxID":"sbx_new"}', + headers: { "Content-Type" => "application/json" } + ) + + expect(client.post("/sandboxes", body: { templateID: "base" })).to eq("sandboxID" => "sbx_new") + end + + it "omits the request body when none is given" do + stub_request(:post, "#{base_url}/sandboxes/sbx_1/pause") + .to_return(status: 204, body: "") + + client.post("/sandboxes/sbx_1/pause") + + expect(a_request(:post, "#{base_url}/sandboxes/sbx_1/pause") + .with { |req| req.body.nil? || req.body.empty? }).to have_been_made + end + end + + describe "#put" do + it "sends a JSON body via PUT" do + stub_request(:put, "#{base_url}/templates/abc") + .with(body: { alias: "latest" }.to_json) + .to_return( + status: 200, + body: '{"ok":true}', + headers: { "Content-Type" => "application/json" } + ) + + expect(client.put("/templates/abc", body: { alias: "latest" })).to eq("ok" => true) + end + end + describe "#delete" do it "sends JSON bodies when provided" do stub_request(:delete, "#{base_url}/templates/tags") diff --git a/spec/e2b/services/base_service_spec.rb b/spec/e2b/services/base_service_spec.rb index 95733ae..abc336a 100644 --- a/spec/e2b/services/base_service_spec.rb +++ b/spec/e2b/services/base_service_spec.rb @@ -70,4 +70,53 @@ def build_service(envd_version: nil) expect(build_service(envd_version: "weird").recursive_watch?).to be(true) end end + + describe "envd client construction and proxy bypass" do + around do |example| + saved = ENV.to_hash.slice("no_proxy", "NO_PROXY") + ENV.delete("no_proxy") + ENV.delete("NO_PROXY") + begin + example.run + ensure + ENV.delete("no_proxy") + ENV.delete("NO_PROXY") + saved.each { |k, v| ENV[k] = v } + end + end + + it "builds an EnvdHttpClient and memoizes it" do + svc = build_service + client = svc.send(:envd_client) + + expect(client).to be_a(E2B::Services::EnvdHttpClient) + expect(svc.send(:envd_client)).to be(client) + end + + it "appends the sandbox domain to no_proxy/NO_PROXY so envd traffic bypasses the proxy" do + build_service.send(:ensure_no_proxy_for_domain!, "e2b.app") + + expect(ENV.fetch("no_proxy", nil)).to include("e2b.app") + expect(ENV.fetch("NO_PROXY", nil)).to include("e2b.app") + end + + it "does not duplicate a domain that is already present" do + ENV["no_proxy"] = "e2b.app" + build_service.send(:ensure_no_proxy_for_domain!, "e2b.app") + + expect(ENV.fetch("no_proxy", nil)).to eq("e2b.app") + end + + it "appends onto an existing no_proxy list" do + ENV["no_proxy"] = "localhost" + build_service.send(:ensure_no_proxy_for_domain!, "e2b.app") + + expect(ENV.fetch("no_proxy", nil)).to eq("localhost,e2b.app") + end + + it "is a no-op for a blank domain" do + build_service.send(:ensure_no_proxy_for_domain!, "") + expect(ENV.fetch("no_proxy", nil)).to be_nil + end + end end diff --git a/spec/e2b/services/envd_http_client_spec.rb b/spec/e2b/services/envd_http_client_spec.rb index eb89767..e3ad313 100644 --- a/spec/e2b/services/envd_http_client_spec.rb +++ b/spec/e2b/services/envd_http_client_spec.rb @@ -139,4 +139,229 @@ def rpc_response(status:, body:, headers: {}) expect(client.send(:extract_error_message, rpc_response(status: 400, body: nil))).to eq("HTTP 400 error") end end + + # Frame one or more JSON strings into a binary Connect envelope stream, the + # wire format handle_rpc_response decodes. + def connect_stream(*messages) + body = "".b + messages.each { |m| body << "\x00".b << [m.bytesize].pack("N") << m.b } + body + end + + describe "#handle_rpc_response" do + it "decodes Data events, parses the End exit code, and collects every event" do + body = connect_stream( + %({"event":{"Data":{"stdout":"#{Base64.strict_encode64("out")}","stderr":"#{Base64.strict_encode64("err")}"}}}), + %({"event":{"End":{"exitCode":0}}}) + ) + + result = client.send(:handle_rpc_response, "process.Process", "Start") do + rpc_response(status: 200, body: body) + end + + expect(result[:stdout]).to eq("out") + expect(result[:stderr]).to eq("err") + expect(result[:exit_code]).to eq(0) + expect(result[:events].size).to eq(2) + end + + it "unwraps a result envelope and reads top-level stdout and a string exit code" do + body = connect_stream( + %({"result":{"stdout":"#{Base64.strict_encode64("hi")}","exitCode":"exit status 3"}}) + ) + + result = client.send(:handle_rpc_response, "s", "m") { rpc_response(status: 200, body: body) } + + expect(result[:stdout]).to eq("hi") + expect(result[:exit_code]).to eq(3) + end + + it "decodes multibyte UTF-8 process output without raising" do + body = connect_stream( + %({"event":{"Data":{"stdout":"#{Base64.strict_encode64("héllo 日本語")}"}}}) + ) + + result = client.send(:handle_rpc_response, "s", "m") { rpc_response(status: 200, body: body) } + + expect(result[:stdout]).to eq("héllo 日本語") + end + + it "skips unparseable frames instead of raising" do + body = connect_stream("{not json", %({"event":{"End":{"exitCode":0}}})) + + result = client.send(:handle_rpc_response, "s", "m") { rpc_response(status: 200, body: body) } + + expect(result[:exit_code]).to eq(0) + expect(result[:events].size).to eq(1) + end + + it "returns an empty hash for an empty body" do + result = client.send(:handle_rpc_response, "s", "m") { rpc_response(status: 200, body: "") } + expect(result).to eq({}) + end + + it "raises a mapped error for a non-success response" do + expect do + client.send(:handle_rpc_response, "s", "m") { rpc_response(status: 404, body: { "message" => "nope" }) } + end.to raise_error(E2B::NotFoundError, "nope") + end + end + + describe "#with_retry" do + it "returns the block result on success" do + expect(client.send(:with_retry, "op") { 42 }).to eq(42) + end + + it "retries transient network errors, then succeeds" do + allow(client).to receive(:sleep) # avoid real 2**n backoff waits + attempts = 0 + + result = client.send(:with_retry, "op") do + attempts += 1 + raise Errno::ECONNRESET if attempts < 3 + + "ok" + end + + expect(result).to eq("ok") + expect(attempts).to eq(3) + end + + it "raises after exhausting retries" do + allow(client).to receive(:sleep) + + expect do + client.send(:with_retry, "op", max_retries: 2) { raise Net::ReadTimeout } + end.to raise_error(E2B::E2BError, /failed after 2 retries/) + end + + it "does not retry when abort_if is true (observable side effects already happened)" do + calls = 0 + + expect do + client.send(:with_retry, "op", abort_if: -> { true }) do + calls += 1 + raise Errno::ECONNRESET + end + end.to raise_error(E2B::E2BError, /failed after partial response/) + + expect(calls).to eq(1) + end + + it "does not retry when max_retries is 0 (non-idempotent /Start)" do + expect do + client.send(:with_retry, "op", max_retries: 0) { raise EOFError } + end.to raise_error(E2B::E2BError, /failed after 0 retries/) + end + + it "propagates errors it does not classify as transient" do + expect { client.send(:with_retry, "op") { raise ArgumentError, "boom" } } + .to raise_error(ArgumentError, "boom") + end + end + + describe "#resolve_proxy" do + around do |example| + keys = %w[no_proxy NO_PROXY http_proxy HTTP_PROXY https_proxy HTTPS_PROXY] + saved = ENV.to_hash.slice(*keys) + keys.each { |k| ENV.delete(k) } + begin + example.run + ensure + keys.each { |k| ENV.delete(k) } + saved.each { |k, v| ENV[k] = v } + end + end + + let(:https_url) { URI.parse("https://49983-sbx.e2b.app/path") } + + it "returns nil when no proxy is configured" do + expect(client.send(:resolve_proxy, https_url)).to be_nil + end + + it "returns the https proxy URI for an https URL" do + ENV["https_proxy"] = "http://proxy.local:3128" + proxy = client.send(:resolve_proxy, https_url) + expect(proxy.host).to eq("proxy.local") + expect(proxy.port).to eq(3128) + end + + it "bypasses the proxy when the host suffix matches no_proxy" do + ENV["https_proxy"] = "http://proxy.local:3128" + ENV["no_proxy"] = "e2b.app" + expect(client.send(:resolve_proxy, https_url)).to be_nil + end + + it "bypasses the proxy for a wildcard no_proxy" do + ENV["https_proxy"] = "http://proxy.local:3128" + ENV["no_proxy"] = "*" + expect(client.send(:resolve_proxy, https_url)).to be_nil + end + + it "returns nil for an unparseable proxy URL" do + client # build the Faraday connection before poisoning the proxy env var + ENV["https_proxy"] = "http://[" + expect(client.send(:resolve_proxy, https_url)).to be_nil + end + end + + describe "#normalize_path" do + it "strips leading slashes so paths join cleanly onto the base URL" do + expect(client.send(:normalize_path, "/files")).to eq("files") + expect(client.send(:normalize_path, "///a/b")).to eq("a/b") + expect(client.send(:normalize_path, "files")).to eq("files") + end + end + + describe "#apply_custom_headers" do + it "sets each header on the request and is a no-op for nil" do + request = Net::HTTP::Post.new("/") + client.send(:apply_custom_headers, request, { "X-Foo" => "bar" }) + expect(request["X-Foo"]).to eq("bar") + + expect { client.send(:apply_custom_headers, request, nil) }.not_to raise_error + end + end + + describe "#handle_response" do + # A minimal stand-in for the Faraday::Response that handle_response inspects. + def faraday_like(success:, body:, headers: {}, status: 200) + resp = Object.new + resp.define_singleton_method(:success?) { success } + resp.define_singleton_method(:body) { body } + resp.define_singleton_method(:headers) { headers } + resp.define_singleton_method(:status) { status } + resp + end + + it "parses a JSON string body" do + resp = faraday_like(success: true, body: '{"ok":true}', headers: { "content-type" => "application/json" }) + expect(client.send(:handle_response) { resp }).to eq("ok" => true) + end + + it "returns a non-JSON string body unchanged" do + resp = faraday_like(success: true, body: "plain text", headers: { "content-type" => "text/plain" }) + expect(client.send(:handle_response) { resp }).to eq("plain text") + end + + it "returns an already-parsed Hash body as-is" do + resp = faraday_like(success: true, body: { "x" => 1 }) + expect(client.send(:handle_response) { resp }).to eq("x" => 1) + end + + it "raises a mapped error for an unsuccessful response" do + resp = faraday_like(success: false, body: { "message" => "bad" }, status: 500) + expect { client.send(:handle_response) { resp } }.to raise_error(E2B::E2BError, "bad") + end + + it "maps Faraday::TimeoutError to E2B::TimeoutError" do + expect { client.send(:handle_response) { raise Faraday::TimeoutError } } + .to raise_error(E2B::TimeoutError) + end + + it "maps Faraday::ConnectionFailed to E2B::E2BError" do + expect { client.send(:handle_response) { raise Faraday::ConnectionFailed, "nope" } } + .to raise_error(E2B::E2BError, /Connection to sandbox failed/) + end + end end diff --git a/spec/e2b/services/filesystem_spec.rb b/spec/e2b/services/filesystem_spec.rb index 8dfcd2c..5acc661 100644 --- a/spec/e2b/services/filesystem_spec.rb +++ b/spec/e2b/services/filesystem_spec.rb @@ -118,4 +118,157 @@ .to raise_error(E2B::TemplateError, /update the template to use recursive watching/) end end + + # An RPC response whose single event carries an "entry" payload, the shape + # Stat/Move responses arrive in once the Connect envelope is parsed. + def entry_response(overrides = {}) + { events: [{ "entry" => { "name" => "f.txt", "type" => 1, "path" => "/tmp/f.txt", "size" => 5 }.merge(overrides) }] } + end + + describe "#get_info" do + it "parses the Stat response into an EntryInfo" do + allow(filesystem).to receive(:envd_rpc).and_return(entry_response) + + info = filesystem.get_info("/tmp/f.txt") + + expect(info).to be_a(E2B::Models::EntryInfo) + expect(info.name).to eq("f.txt") + expect(info).to be_file + expect(info.size).to eq(5) + end + end + + describe "#exists?" do + it "returns true when Stat succeeds" do + allow(filesystem).to receive(:envd_rpc).and_return(entry_response) + expect(filesystem.exists?("/tmp/f.txt")).to be(true) + end + + it "returns false only for NotFoundError" do + allow(filesystem).to receive(:envd_rpc).and_raise(E2B::NotFoundError.new("gone", status_code: 404)) + expect(filesystem.exists?("/missing")).to be(false) + end + + it "propagates errors other than NotFoundError so callers can tell 'gone' from 'could not ask'" do + allow(filesystem).to receive(:envd_rpc).and_raise(E2B::E2BError.new("network down")) + expect { filesystem.exists?("/x") }.to raise_error(E2B::E2BError, "network down") + end + end + + describe "#rename" do + it "sends source/destination and parses the moved entry" do + expect(filesystem).to receive(:envd_rpc) + .with("filesystem.Filesystem", "Move", + hash_including(body: { source: "/a.txt", destination: "/b.txt" })) + .and_return(entry_response("name" => "b.txt", "path" => "/b.txt")) + + info = filesystem.rename("/a.txt", "/b.txt") + expect(info.path).to eq("/b.txt") + end + end + + describe "#make_dir" do + it "returns true after issuing the MakeDir RPC" do + expect(filesystem).to receive(:envd_rpc) + .with("filesystem.Filesystem", "MakeDir", hash_including(body: { path: "/tmp/new" })) + .and_return({}) + + expect(filesystem.make_dir("/tmp/new")).to be(true) + end + end + + describe "#remove" do + it "issues the Remove RPC for the path" do + expect(filesystem).to receive(:envd_rpc) + .with("filesystem.Filesystem", "Remove", hash_including(body: { path: "/tmp/gone" })) + .and_return({}) + + filesystem.remove("/tmp/gone") + end + end + + describe "#write_files" do + it "writes each entry and accepts both :data and :content keys" do + allow(filesystem).to receive(:write) do |path, data, **| + E2B::Models::WriteInfo.new(path: "#{path}:#{data}") + end + + infos = filesystem.write_files([ + { path: "/a.txt", data: "A" }, + { path: "/b.txt", content: "B" } + ]) + + expect(infos.map(&:path)).to eq(["/a.txt:A", "/b.txt:B"]) + end + end + + describe "backward-compatible aliases" do + it "maps legacy method names onto their modern equivalents" do + expect(filesystem.method(:read_file)).to eq(filesystem.method(:read)) + expect(filesystem.method(:write_file)).to eq(filesystem.method(:write)) + expect(filesystem.method(:list_files)).to eq(filesystem.method(:list)) + expect(filesystem.method(:mkdir)).to eq(filesystem.method(:make_dir)) + expect(filesystem.method(:create_folder)).to eq(filesystem.method(:make_dir)) + expect(filesystem.method(:move)).to eq(filesystem.method(:rename)) + expect(filesystem.method(:move_files)).to eq(filesystem.method(:rename)) + expect(filesystem.method(:delete_file)).to eq(filesystem.method(:remove)) + end + end + + describe "private response parsing" do + it "build_file_url percent-encodes the path and username query params" do + url = filesystem.send(:build_file_url, "/files", path: "/tmp/a b.txt", user: "alice") + expect(url).to eq("https://49983-sbx_123.custom.e2b.test/files?path=%2Ftmp%2Fa+b.txt&username=alice") + end + + it "extract_entries reads entries from a direct event field" do + response = { events: [{ "entries" => [{ "name" => "a" }] }] } + expect(filesystem.send(:extract_entries, response)).to eq([{ "name" => "a" }]) + end + + it "extract_entries reads entries nested under result" do + response = { events: [{ "result" => { "entries" => [{ "name" => "b" }] } }] } + expect(filesystem.send(:extract_entries, response)).to eq([{ "name" => "b" }]) + end + + it "extract_entries falls back to a top-level entries key" do + expect(filesystem.send(:extract_entries, { "entries" => [{ "name" => "c" }] })).to eq([{ "name" => "c" }]) + end + + it "extract_entries returns [] for a non-Hash response" do + expect(filesystem.send(:extract_entries, "nope")).to eq([]) + end + + it "extract_entry prefers an event entry, then result.entry, then top-level" do + expect(filesystem.send(:extract_entry, { events: [{ "entry" => { "name" => "x" } }] })).to eq({ "name" => "x" }) + expect(filesystem.send(:extract_entry, { events: [{ "result" => { "entry" => { "name" => "y" } } }] })) + .to eq({ "name" => "y" }) + expect(filesystem.send(:extract_entry, { "entry" => { "name" => "z" } })).to eq({ "name" => "z" }) + expect(filesystem.send(:extract_entry, "nope")).to eq({}) + end + + it "parse_upload_response returns [] for blank bodies and invalid JSON" do + expect(filesystem.send(:parse_upload_response, "")).to eq([]) + expect(filesystem.send(:parse_upload_response, nil)).to eq([]) + expect(filesystem.send(:parse_upload_response, "{not json")).to eq([]) + expect(filesystem.send(:parse_upload_response, '[{"path":"/x"}]')).to eq([{ "path" => "/x" }]) + end + + it "build_write_info handles Hash, Array, and fallback shapes" do + from_hash = filesystem.send(:build_write_info, { "path" => "/from-hash" }, default_path: "/d") + expect(from_hash.path).to eq("/from-hash") + + from_array = filesystem.send(:build_write_info, [{ "path" => "/from-array" }], default_path: "/d") + expect(from_array.path).to eq("/from-array") + + empty_array = filesystem.send(:build_write_info, [], default_path: "/default") + expect(empty_array.path).to eq("/default") + + hash_without_path = filesystem.send(:build_write_info, {}, default_path: "/default") + expect(hash_without_path.path).to eq("/default") + + nil_result = filesystem.send(:build_write_info, nil, default_path: "/default") + expect(nil_result.path).to eq("/default") + end + end end diff --git a/spec/e2b/services/git_spec.rb b/spec/e2b/services/git_spec.rb index 0c5f86b..7de4c38 100644 --- a/spec/e2b/services/git_spec.rb +++ b/spec/e2b/services/git_spec.rb @@ -161,4 +161,354 @@ def process_result(stdout: "", stderr: "", exit_code: 0) expect(received_envs["GIT_TERMINAL_PROMPT"]).to eq("0") end end + + # Records every command string handed to commands.run, in order, so that + # multi-step flows (push/pull with credentials) can be asserted as a sequence. + # Queued +results+ are returned one per call; once exhausted, a success result + # is returned so cleanup steps still observe a healthy exit. + def stub_run_sequence(*results) + calls = [] + allow(commands).to receive(:run) do |cmd, **kwargs| + calls << { cmd: cmd, kwargs: kwargs } + results.empty? ? process_result : (results.shift || process_result) + end + calls + end + + # Capture just the command string produced by a single delegated call. + def captured_cmd + received = nil + allow(commands).to receive(:run) do |cmd, **| + received = cmd + process_result + end + yield + received + end + + describe "#init" do + it "builds a plain init command with the path appended" do + expect(captured_cmd { git.init("/repo") }).to eq("git init #{Shellwords.escape("/repo")}") + end + + it "passes --bare and --initial-branch when requested" do + cmd = captured_cmd { git.init("/repo", bare: true, initial_branch: "main") } + expect(cmd).to eq("git init --bare --initial-branch main #{Shellwords.escape("/repo")}") + end + end + + describe "#remote_add" do + it "adds the remote under the repo path by default" do + cmd = captured_cmd { git.remote_add("/repo", "origin", "https://github.com/o/r.git") } + expect(cmd).to eq("git -C #{Shellwords.escape("/repo")} remote add origin " \ + "#{Shellwords.escape("https://github.com/o/r.git")}") + end + + it "fetches after adding when fetch: true" do + calls = stub_run_sequence + git.remote_add("/repo", "origin", "https://github.com/o/r.git", fetch: true) + + cmds = calls.map { |c| c[:cmd] } + expect(cmds.last).to eq("git -C #{Shellwords.escape("/repo")} fetch origin") + end + + it "set-urls an existing remote when overwrite: true and the remote exists" do + # First call is remote_get returning a URL → remote exists → set-url. + calls = stub_run_sequence(process_result(stdout: "https://old.example/r.git\n")) + git.remote_add("/repo", "origin", "https://new.example/r.git", overwrite: true) + + cmds = calls.map { |c| c[:cmd] } + expect(cmds[0]).to eq("git -C #{Shellwords.escape("/repo")} remote get-url origin") + expect(cmds[1]).to eq("git -C #{Shellwords.escape("/repo")} remote set-url origin " \ + "#{Shellwords.escape("https://new.example/r.git")}") + end + + it "adds a new remote when overwrite: true but the remote does not exist" do + # remote_get returns a failure → nil → fall back to `remote add`. + calls = stub_run_sequence(process_result(exit_code: 2)) + git.remote_add("/repo", "origin", "https://new.example/r.git", overwrite: true) + + cmds = calls.map { |c| c[:cmd] } + expect(cmds[1]).to eq("git -C #{Shellwords.escape("/repo")} remote add origin " \ + "#{Shellwords.escape("https://new.example/r.git")}") + end + end + + describe "#remote_get" do + it "returns the trimmed remote URL on success" do + allow(commands).to receive(:run).and_return(process_result(stdout: "https://github.com/o/r.git\n")) + expect(git.remote_get("/repo", "origin")).to eq("https://github.com/o/r.git") + end + + it "returns nil when the command fails" do + allow(commands).to receive(:run).and_return(process_result(exit_code: 2, stderr: "No such remote")) + expect(git.remote_get("/repo", "origin")).to be_nil + end + + it "returns nil when the URL is blank" do + allow(commands).to receive(:run).and_return(process_result(stdout: " \n")) + expect(git.remote_get("/repo", "origin")).to be_nil + end + end + + describe "branch operations" do + it "creates, checks out, and deletes branches" do + expect(captured_cmd { git.create_branch("/repo", "feature") }) + .to eq("git -C #{Shellwords.escape("/repo")} branch feature") + expect(captured_cmd { git.checkout_branch("/repo", "feature") }) + .to eq("git -C #{Shellwords.escape("/repo")} checkout feature") + expect(captured_cmd { git.delete_branch("/repo", "feature") }) + .to eq("git -C #{Shellwords.escape("/repo")} branch -d feature") + end + + it "force-deletes with -D when force: true" do + expect(captured_cmd { git.delete_branch("/repo", "feature", force: true) }) + .to eq("git -C #{Shellwords.escape("/repo")} branch -D feature") + end + end + + describe "#add" do + it "stages everything with -A by default" do + expect(captured_cmd { git.add("/repo") }) + .to eq("git -C #{Shellwords.escape("/repo")} add -A") + end + + it "stages specific escaped files when all: false" do + cmd = captured_cmd { git.add("/repo", all: false, files: ["a.rb", "spaced name.rb"]) } + expect(cmd).to eq("git -C #{Shellwords.escape("/repo")} add " \ + "#{Shellwords.escape("a.rb")} #{Shellwords.escape("spaced name.rb")}") + end + + it "falls back to -A when all: false but no files are given" do + expect(captured_cmd { git.add("/repo", all: false, files: []) }) + .to eq("git -C #{Shellwords.escape("/repo")} add -A") + end + end + + describe "#commit" do + it "escapes the message and can allow empty commits" do + cmd = captured_cmd { git.commit("/repo", "initial commit", allow_empty: true) } + expect(cmd).to eq("git -C #{Shellwords.escape("/repo")} commit -m " \ + "#{Shellwords.escape("initial commit")} --allow-empty") + end + + it "sets author and committer env vars when both name and email are given" do + received_envs = nil + allow(commands).to receive(:run) do |_cmd, envs:, **| + received_envs = envs + process_result + end + + git.commit("/repo", "msg", author_name: "Bot", author_email: "bot@example.com") + + expect(received_envs).to include( + "GIT_AUTHOR_NAME" => "Bot", + "GIT_COMMITTER_NAME" => "Bot", + "GIT_AUTHOR_EMAIL" => "bot@example.com", + "GIT_COMMITTER_EMAIL" => "bot@example.com" + ) + end + + it "does not set author env vars when only one of name/email is given" do + received_envs = nil + allow(commands).to receive(:run) do |_cmd, envs:, **| + received_envs = envs + process_result + end + + git.commit("/repo", "msg", author_name: "Bot") + + expect(received_envs).not_to have_key("GIT_AUTHOR_NAME") + end + end + + describe "#reset" do + it "builds reset with a mode and target" do + expect(captured_cmd { git.reset("/repo", mode: "hard", target: "HEAD~1") }) + .to eq("git -C #{Shellwords.escape("/repo")} reset --hard HEAD~1") + end + + it "appends a -- separator and escaped paths when unstaging specific files" do + cmd = captured_cmd { git.reset("/repo", paths: ["a.rb", "b c.rb"]) } + expect(cmd).to eq("git -C #{Shellwords.escape("/repo")} reset -- " \ + "#{Shellwords.escape("a.rb")} #{Shellwords.escape("b c.rb")}") + end + end + + describe "#restore" do + it "restores an array of paths with --staged and --worktree flags" do + cmd = captured_cmd { git.restore("/repo", ["a.rb", "b.rb"], staged: true, worktree: true) } + expect(cmd).to eq("git -C #{Shellwords.escape("/repo")} restore --staged --worktree " \ + "#{Shellwords.escape("a.rb")} #{Shellwords.escape("b.rb")}") + end + + it "restores a single path string from a given source" do + cmd = captured_cmd { git.restore("/repo", "a.rb", source: "HEAD") } + expect(cmd).to eq("git -C #{Shellwords.escape("/repo")} restore --source HEAD " \ + "#{Shellwords.escape("a.rb")}") + end + end + + describe "#push with credentials" do + it "temporarily authenticates the remote URL and restores it afterwards" do + calls = stub_run_sequence( + process_result(stdout: "https://github.com/o/r.git\n") # remote_get + ) + + git.push("/repo", remote: "origin", branch: "main", + username: "alice", password: "tok") + + cmds = calls.map { |c| c[:cmd] } + expect(cmds[0]).to eq("git -C #{Shellwords.escape("/repo")} remote get-url origin") + expect(cmds[1]).to eq("git -C #{Shellwords.escape("/repo")} remote set-url origin " \ + "#{Shellwords.escape("https://alice:tok@github.com/o/r.git")}") + expect(cmds[2]).to eq("git -C #{Shellwords.escape("/repo")} push -u origin main") + # Cleanup must strip the credentials back out of the stored URL. + expect(cmds[3]).to eq("git -C #{Shellwords.escape("/repo")} remote set-url origin " \ + "#{Shellwords.escape("https://github.com/o/r.git")}") + end + + it "restores the original URL even when the push itself fails" do + calls = stub_run_sequence( + process_result(stdout: "https://github.com/o/r.git\n"), # remote_get + process_result, # set-url authed + process_result(exit_code: 128, stderr: "fatal: Authentication failed") # push + ) + + expect do + git.push("/repo", username: "alice", password: "tok") + end.to raise_error(E2B::GitAuthError) + + # The ensure block still ran the credential-stripping set-url. + expect(calls.last[:cmd]).to eq("git -C #{Shellwords.escape("/repo")} remote set-url origin " \ + "#{Shellwords.escape("https://github.com/o/r.git")}") + end + + it "raises E2BError when the remote to authenticate does not exist" do + stub_run_sequence(process_result(exit_code: 2)) # remote_get fails → nil + + expect do + git.push("/repo", username: "alice", password: "tok") + end.to raise_error(E2B::E2BError, /Remote 'origin' not found/) + end + end + + describe "#pull" do + it "pulls from the default origin remote" do + expect(captured_cmd { git.pull("/repo", branch: "main") }) + .to eq("git -C #{Shellwords.escape("/repo")} pull origin main") + end + + it "raises GitAuthError when authentication fails" do + allow(commands).to receive(:run).and_return( + process_result(exit_code: 128, stderr: "fatal: could not read Username") + ) + + expect { git.pull("/repo") }.to raise_error(E2B::GitAuthError) + end + end + + describe "git config" do + it "rejects an invalid scope" do + expect { git.set_config("user.name", "Alice", scope: "bogus") } + .to raise_error(E2B::E2BError, /Invalid git config scope/) + end + + it "writes a scoped, escaped config value" do + expect(captured_cmd { git.set_config("user.name", "Alice Smith", scope: "global") }) + .to eq("git config --global user.name #{Shellwords.escape("Alice Smith")}") + end + + it "reads a config value, returning nil when unset" do + allow(commands).to receive(:run).and_return(process_result(stdout: "Alice\n")) + expect(git.get_config("user.name")).to eq("Alice") + + allow(commands).to receive(:run).and_return(process_result(exit_code: 1)) + expect(git.get_config("user.name")).to be_nil + end + + it "configure_user sets both user.name and user.email" do + calls = stub_run_sequence + git.configure_user("Alice", "alice@example.com", scope: "local", path: "/repo") + + cmds = calls.map { |c| c[:cmd] } + expect(cmds).to contain_exactly( + "git -C #{Shellwords.escape("/repo")} config --local user.name #{Shellwords.escape("Alice")}", + "git -C #{Shellwords.escape("/repo")} config --local user.email #{Shellwords.escape("alice@example.com")}" + ) + end + end + + describe "#dangerously_authenticate" do + it "enables the store helper and pipes credentials into git credential approve" do + calls = stub_run_sequence + git.dangerously_authenticate("alice", "tok", host: "github.com") + + cmds = calls.map { |c| c[:cmd] } + expect(cmds[0]).to eq("git config --global credential.helper #{Shellwords.escape("store")}") + # The credential block is shell-escaped as a single echo argument, so the + # raw `username=alice` becomes `username\=alice` once Shellwords escapes `=`. + expect(cmds[1]).to start_with("echo ") + expect(cmds[1]).to include("| git credential approve") + expect(cmds[1]).to include("alice") + expect(cmds[1]).to include("tok") + expect(cmds[1]).to include("host\\=github.com") + end + end + + describe "#status parsing of rename and unmerged entries" do + it "uses the new path for renames and flags staged/conflicted/clean states" do + raw = <<~PORCELAIN + # branch.head main + 2 R. N... 100644 100644 100644 deadbeef deadbeef R100 new_name.rb\told_name.rb + u UU N... 100644 100644 100644 100644 aaa bbb ccc conflicted.rb + 1 M. N... 100644 100644 100644 deadbeef deadbeef staged.rb + PORCELAIN + + allow(commands).to receive(:run).and_return(process_result(stdout: raw)) + status = git.status("/repo") + + paths = status.file_status.map(&:path) + expect(paths).to include("new_name.rb", "conflicted.rb", "staged.rb") + expect(paths).not_to include("new_name.rb\told_name.rb") + + expect(status.has_staged?).to be(true) + expect(status.has_conflicts?).to be(true) + expect(status.conflict_count).to eq(1) + # staged_count counts any entry whose index status is neither "." nor "?", + # which includes the unmerged ("u") entry — so rename + unmerged + modified. + expect(status.staged_count).to eq(3) + end + end + + describe E2B::Services::GitStatus do + def fs(index, work, path = "f") + E2B::Services::GitFileStatus.new(path: path, index_status: index, work_tree_status: work) + end + + it "reports a clean tree when there are no entries" do + status = described_class.new + expect(status).to be_clean + expect(status.has_changes?).to be(false) + end + + it "counts staged, untracked, conflicted, and modified entries independently" do + status = described_class.new(file_status: [ + fs("M", ".", "staged.rb"), # staged + fs("?", "?", "untracked.rb"), # untracked + fs("u", "U", "conflict.rb"), # conflict + fs(".", "M", "dirty.rb") # modified worktree only + ]) + + expect(status).to have_changes + expect(status.has_staged?).to be(true) + # Both the "M" and the conflicted "u" index statuses count as staged. + expect(status.staged_count).to eq(2) + expect(status.has_untracked?).to be(true) + expect(status.untracked_count).to eq(1) + expect(status.has_conflicts?).to be(true) + expect(status.conflict_count).to eq(1) + expect(status.modified_count).to eq(1) + end + end end