diff --git a/.changeset/odd-drinks-walk.md b/.changeset/odd-drinks-walk.md new file mode 100644 index 0000000000..227f2a884b --- /dev/null +++ b/.changeset/odd-drinks-walk.md @@ -0,0 +1,6 @@ +--- +'@e2b/python-sdk': minor +'e2b': minor +--- + +Adds first class support to git commands to the sandbox sdk. diff --git a/packages/js-sdk/src/errors.ts b/packages/js-sdk/src/errors.ts index 1e3eb6ebfa..ad51c1f2a4 100644 --- a/packages/js-sdk/src/errors.ts +++ b/packages/js-sdk/src/errors.ts @@ -78,6 +78,26 @@ export class AuthenticationError extends Error { } } +/** + * Thrown when git authentication fails. + */ +export class GitAuthError extends AuthenticationError { + constructor(message: string) { + super(message) + this.name = 'GitAuthError' + } +} + +/** + * Thrown when git upstream tracking is missing. + */ +export class GitUpstreamError extends SandboxError { + constructor(message: string, stackTrace?: string) { + super(message, stackTrace) + this.name = 'GitUpstreamError' + } +} + /** * Thrown when the template uses old envd version. It isn't compatible with the new SDK. */ diff --git a/packages/js-sdk/src/index.ts b/packages/js-sdk/src/index.ts index 61c13ac1a5..0be376dbf9 100644 --- a/packages/js-sdk/src/index.ts +++ b/packages/js-sdk/src/index.ts @@ -5,6 +5,8 @@ export { ConnectionConfig } from './connectionConfig' export type { ConnectionOpts, Username } from './connectionConfig' export { AuthenticationError, + GitAuthError, + GitUpstreamError, InvalidArgumentError, NotEnoughSpaceError, NotFoundError, @@ -62,6 +64,25 @@ export type { Pty, } from './sandbox/commands' +export { Git } from './sandbox/git' +export type { + GitRequestOpts, + GitCloneOpts, + GitInitOpts, + GitRemoteAddOpts, + GitCommitOpts, + GitAddOpts, + GitDeleteBranchOpts, + GitPushOpts, + GitPullOpts, + GitDangerouslyAuthenticateOpts, + GitConfigOpts, + GitConfigScope, + GitBranches, + GitFileStatus, + GitStatus, +} from './sandbox/git' + export { Sandbox } import { Sandbox } from './sandbox' diff --git a/packages/js-sdk/src/sandbox/git/index.ts b/packages/js-sdk/src/sandbox/git/index.ts new file mode 100644 index 0000000000..c0ec3a17ea --- /dev/null +++ b/packages/js-sdk/src/sandbox/git/index.ts @@ -0,0 +1,1054 @@ +import { + GitAuthError, + GitUpstreamError, + InvalidArgumentError, +} from '../../errors' +import type { CommandStartOpts } from '../commands' +import type { CommandResult } from '../commands/commandHandle' +import { Commands } from '../commands' +import { + buildAuthErrorMessage, + buildGitCommand, + buildPushArgs, + buildUpstreamErrorMessage, + GitBranches, + GitConfigScope, + GitStatus, + getRepoPathForScope, + getScopeFlag, + isAuthFailure, + isMissingUpstream, + parseGitBranches, + parseGitStatus, + shellEscape, + stripCredentials, + deriveRepoDirFromUrl, + withCredentials, +} from './utils' + +const DEFAULT_GIT_ENV: Record = { + GIT_TERMINAL_PROMPT: '0', +} + +/** + * Options for git operations in the sandbox. + */ +export interface GitRequestOpts + extends Partial< + Pick< + CommandStartOpts, + 'envs' | 'user' | 'cwd' | 'timeoutMs' | 'requestTimeoutMs' + > + > {} + +/** + * Options for cloning a repository. + */ +export interface GitCloneOpts extends GitRequestOpts { + /** + * Destination path for the clone. + */ + path?: string + /** + * Branch to check out. + */ + branch?: string + /** + * If set, perform a shallow clone with this depth. + */ + depth?: number + /** + * Username for HTTP(S) authentication. + */ + username?: string + /** + * Password or token for HTTP(S) authentication. + */ + password?: string + /** + * Store credentials in the cloned repository when `true`. + * + * @default false + */ + dangerouslyStoreCredentials?: boolean +} + +/** + * Options for initializing a repository. + */ +export interface GitInitOpts extends GitRequestOpts { + /** + * Create a bare repository when `true`. + */ + bare?: boolean + /** + * Initial branch name (for example, `"main"`). + */ + initialBranch?: string +} + +/** + * Options for adding a git remote. + */ +export interface GitRemoteAddOpts extends GitRequestOpts { + /** + * Fetch the remote after adding it when `true`. + */ + fetch?: boolean + /** + * Overwrite the remote URL if the remote already exists when `true`. + */ + overwrite?: boolean +} + +/** + * Options for creating a commit. + */ +export interface GitCommitOpts extends GitRequestOpts { + /** + * Commit author name. + */ + authorName?: string + /** + * Commit author email. + */ + authorEmail?: string + /** + * Allow empty commits when `true`. + */ + allowEmpty?: boolean +} + +/** + * Options for staging files. + */ +export interface GitAddOpts extends GitRequestOpts { + /** + * Files to add; when omitted, adds the current directory. + */ + files?: string[] + /** + * When `true` and `files` is omitted, stage all changes. + */ + all?: boolean +} + +/** + * Supported reset modes. + */ +export type GitResetMode = 'soft' | 'mixed' | 'hard' | 'merge' | 'keep' + +/** + * Options for resetting a repository. + */ +export interface GitResetOpts extends GitRequestOpts { + /** + * Reset mode to use. + */ + mode?: GitResetMode + /** + * Commit, branch, or ref to reset to (defaults to HEAD). + */ + target?: string + /** + * Paths to reset. + */ + paths?: string[] +} + +/** + * Options for restoring files or unstaging changes. + */ +export interface GitRestoreOpts extends GitRequestOpts { + /** + * Paths to restore (use `['.']` for all). + */ + paths: string[] + /** + * Restore the index (unstage). + */ + staged?: boolean + /** + * Restore working tree files. + */ + worktree?: boolean + /** + * Restore from the given source (commit, branch, or ref). + */ + source?: string +} +/** + * Options for deleting a branch. + */ +export interface GitDeleteBranchOpts extends GitRequestOpts { + /** + * Force deletion with `-D` when `true`. + */ + force?: boolean +} + +/** + * Options for pushing commits. + */ +export interface GitPushOpts extends GitRequestOpts { + /** + * Remote name (for example, `"origin"`). + */ + remote?: string + /** + * Branch name to push. + */ + branch?: string + /** + * Set upstream tracking when `true`. + */ + setUpstream?: boolean + /** + * Username for HTTP(S) authentication. + */ + username?: string + /** + * Password or token for HTTP(S) authentication. + */ + password?: string +} + +/** + * Options for pulling commits. + */ +export interface GitPullOpts extends GitRequestOpts { + /** + * Remote name (for example, `"origin"`). + */ + remote?: string + /** + * Branch name to pull. + */ + branch?: string + /** + * Username for HTTP(S) authentication. + */ + username?: string + /** + * Password or token for HTTP(S) authentication. + */ + password?: string +} + +/** + * Supported scopes for git config operations. + */ +/** + * Options for git config operations. + */ +export interface GitConfigOpts extends GitRequestOpts { + /** + * Scope for the git config command. + * + * @default "global" + */ + scope?: GitConfigScope + /** + * Repository path required when `scope` is `"local"`. + */ + path?: string +} + +/** + * Options for dangerously authenticating git globally via the credential helper. + */ +export interface GitDangerouslyAuthenticateOpts extends GitRequestOpts { + /** + * Username for HTTP(S) authentication. + */ + username: string + /** + * Password or token for HTTP(S) authentication. + */ + password: string + /** + * Host to authenticate for. + * + * @default "github.com" + */ + host?: string + /** + * Protocol to authenticate for. + * + * @default "https" + */ + protocol?: string +} + +/** + * Module for running git operations in the sandbox. + */ +export class Git { + constructor(private readonly commands: Commands) {} + + /** + * Clone a git repository into the sandbox. + * + * @param url Git repository URL. + * @param opts Clone options. + * @returns Command result from the command runner. + */ + async clone(url: string, opts?: GitCloneOpts): Promise { + const { + username, + password, + branch, + depth, + path, + dangerouslyStoreCredentials, + ...rest + } = opts ?? {} + + if (password && !username) { + throw new InvalidArgumentError( + 'Username is required when using a password or token for git clone.' + ) + } + + const attemptClone = async ( + authUsername?: string, + authPassword?: string + ): Promise => { + const urlWithCreds = + authUsername && authPassword + ? withCredentials(url, authUsername, authPassword) + : url + + const sanitizedUrl = stripCredentials(urlWithCreds) + const stripInlineCreds = + !dangerouslyStoreCredentials && sanitizedUrl !== urlWithCreds + + const repoPath = stripInlineCreds + ? (path ?? deriveRepoDirFromUrl(url)) + : path + + if (stripInlineCreds && !repoPath) { + throw new InvalidArgumentError( + 'A destination path is required when using credentials without storing them.' + ) + } + + const args = ['clone', urlWithCreds] + + if (branch) args.push('--branch', branch, '--single-branch') + if (depth) args.push('--depth', depth.toString()) + if (repoPath) args.push(repoPath) + + const result = await this.runGit(args, undefined, rest) + + if (stripInlineCreds) { + await this.runGit( + ['remote', 'set-url', 'origin', sanitizedUrl], + repoPath, + rest + ) + } + + return result + } + + try { + return await attemptClone(username, password) + } catch (err) { + if (isAuthFailure(err)) { + throw new GitAuthError( + buildAuthErrorMessage('clone', Boolean(username) && !password) + ) + } + throw err + } + } + + /** + * Initialize a new git repository. + * + * @param path Destination path for the repository. + * @param opts Init options. + * @returns Command result from the command runner. + */ + async init(path: string, opts?: GitInitOpts): Promise { + const { bare, initialBranch, ...rest } = opts ?? {} + const args = ['init'] + + if (initialBranch) { + args.push('--initial-branch', initialBranch) + } + if (bare) { + args.push('--bare') + } + + args.push(path) + return this.runGit(args, undefined, rest) + } + + /** + * Add (or update) a remote for a repository. + * + * @param path Repository path. + * @param name Remote name (for example, `"origin"`). + * @param url Remote URL. + * @param opts Remote add options. + * @returns Command result from the command runner. + */ + async remoteAdd( + path: string, + name: string, + url: string, + opts?: GitRemoteAddOpts + ): Promise { + if (!name || !url) { + throw new InvalidArgumentError( + 'Both remote name and URL are required to add a git remote.' + ) + } + + const { fetch, overwrite, ...rest } = opts ?? {} + const addArgs = ['remote', 'add'] + + if (fetch) { + addArgs.push('-f') + } + + addArgs.push(name, url) + + if (!overwrite) { + return this.runGit(addArgs, path, rest) + } + + const addCmd = buildGitCommand(addArgs, path) + const setUrlCmd = buildGitCommand(['remote', 'set-url', name, url], path) + let cmd = `${addCmd} || ${setUrlCmd}` + if (fetch) { + const fetchCmd = buildGitCommand(['fetch', name], path) + cmd = `(${cmd}) && ${fetchCmd}` + } + return this.runShell(cmd, rest) + } + + /** + * Get the URL for a git remote. + * + * Returns `undefined` when the remote does not exist. + * + * @param path Repository path. + * @param name Remote name (for example, `"origin"`). + * @param opts Command execution options. + * @returns Remote URL if present. + */ + async remoteGet( + path: string, + name: string, + opts?: GitRequestOpts + ): Promise { + if (!name) { + throw new InvalidArgumentError('Remote name is required.') + } + + const cmd = `${buildGitCommand(['remote', 'get-url', name], path)} || true` + const result = await this.runShell(cmd, opts) + const trimmed = result.stdout.trim() + return trimmed.length > 0 ? trimmed : undefined + } + + /** + * Get repository status information. + * + * @param path Repository path. + * @param opts Command execution options. + * @returns Parsed git status. + */ + async status(path: string, opts?: GitRequestOpts): Promise { + const result = await this.runGit( + ['status', '--porcelain=1', '-b'], + path, + opts + ) + return parseGitStatus(result.stdout) + } + + /** + * List branches in a repository. + * + * @param path Repository path. + * @param opts Command execution options. + * @returns Parsed branch list. + */ + async branches(path: string, opts?: GitRequestOpts): Promise { + const result = await this.runGit( + ['branch', '--format=%(refname:short)\t%(HEAD)'], + path, + opts + ) + return parseGitBranches(result.stdout) + } + + /** + * Create and check out a new branch. + * + * @param path Repository path. + * @param branch Branch name to create. + * @param opts Command execution options. + * @returns Command result from the command runner. + */ + async createBranch( + path: string, + branch: string, + opts?: GitRequestOpts + ): Promise { + return this.runGit(['checkout', '-b', branch], path, opts) + } + + /** + * Check out an existing branch. + * + * @param path Repository path. + * @param branch Branch name to check out. + * @param opts Command execution options. + * @returns Command result from the command runner. + */ + async checkoutBranch( + path: string, + branch: string, + opts?: GitRequestOpts + ): Promise { + return this.runGit(['checkout', branch], path, opts) + } + + /** + * Delete a branch. + * + * @param path Repository path. + * @param branch Branch name to delete. + * @param opts Delete options. + * @returns Command result from the command runner. + */ + async deleteBranch( + path: string, + branch: string, + opts?: GitDeleteBranchOpts + ): Promise { + const { force, ...rest } = opts ?? {} + const args = ['branch', force ? '-D' : '-d', branch] + return this.runGit(args, path, rest) + } + + /** + * Stage files for commit. + * + * @param path Repository path. + * @param opts Add options. + * @returns Command result from the command runner. + */ + async add(path: string, opts?: GitAddOpts): Promise { + const { files, all = true, ...rest } = opts ?? {} + const args = ['add'] + + if (!files || files.length === 0) { + args.push(all ? '-A' : '.') + } else { + args.push('--', ...files) + } + + return this.runGit(args, path, rest) + } + + /** + * Create a commit in the repository. + * + * @param path Repository path. + * @param message Commit message. + * @param opts Commit options. + * @returns Command result from the command runner. + */ + async commit( + path: string, + message: string, + opts?: GitCommitOpts + ): Promise { + const { authorName, authorEmail, allowEmpty, ...rest } = opts ?? {} + const args = ['commit', '-m', message] + + if (allowEmpty) { + args.push('--allow-empty') + } + + const authorArgs: string[] = [] + if (authorName) { + authorArgs.push('-c', `user.name=${authorName}`) + } + if (authorEmail) { + authorArgs.push('-c', `user.email=${authorEmail}`) + } + + return this.runGit([...authorArgs, ...args], path, rest) + } + + /** + * Reset the current HEAD to a specified state. + * + * @param path Repository path. + * @param opts Reset options. + * @returns Command result from the command runner. + */ + async reset(path: string, opts?: GitResetOpts): Promise { + const { mode, target, paths, ...rest } = opts ?? {} + const allowedModes: GitResetMode[] = [ + 'soft', + 'mixed', + 'hard', + 'merge', + 'keep', + ] + + if (mode && !allowedModes.includes(mode)) { + throw new InvalidArgumentError( + `Reset mode must be one of ${allowedModes.join(', ')}.` + ) + } + + const args = ['reset'] + if (mode) { + args.push(`--${mode}`) + } + if (target) { + args.push(target) + } + if (paths && paths.length > 0) { + args.push('--', ...paths) + } + + return this.runGit(args, path, rest) + } + + /** + * Restore working tree files or unstage changes. + * + * @param path Repository path. + * @param opts Restore options. + * @returns Command result from the command runner. + */ + async restore(path: string, opts: GitRestoreOpts): Promise { + const { paths, staged, worktree, source, ...rest } = opts + + if (!paths || paths.length === 0) { + throw new InvalidArgumentError('At least one path is required.') + } + + let resolvedStaged = staged + let resolvedWorktree = worktree + + if (staged === undefined && worktree === undefined) { + resolvedWorktree = true + } else if (staged === true && worktree === undefined) { + resolvedWorktree = false + } else if (staged === undefined && worktree !== undefined) { + resolvedStaged = false + } + + if (resolvedStaged === false && resolvedWorktree === false) { + throw new InvalidArgumentError( + 'At least one of staged or worktree must be true.' + ) + } + + const args = ['restore'] + if (resolvedWorktree) { + args.push('--worktree') + } + if (resolvedStaged) { + args.push('--staged') + } + if (source) { + args.push('--source', source) + } + args.push('--', ...paths) + + return this.runGit(args, path, rest) + } + + /** + * Push commits to a remote. + * + * @param path Repository path. + * @param opts Push options. + * @returns Command result from the command runner. + */ + async push(path: string, opts?: GitPushOpts): Promise { + const { + remote, + branch, + setUpstream = true, + username, + password, + ...rest + } = opts ?? {} + + if (password && !username) { + throw new InvalidArgumentError( + 'Username is required when using a password or token for git push.' + ) + } + + if (username && password) { + const remoteName = await this.resolveRemoteName(path, remote, rest) + return this.withRemoteCredentials( + path, + remoteName, + username, + password, + rest, + () => + this.runGit( + buildPushArgs(remoteName, { remote, branch, setUpstream }), + path, + rest + ) + ) + } + + try { + return await this.runGit( + buildPushArgs(undefined, { remote, branch, setUpstream }), + path, + rest + ) + } catch (err) { + if (isAuthFailure(err)) { + throw new GitAuthError( + buildAuthErrorMessage('push', Boolean(username) && !password) + ) + } + if (isMissingUpstream(err)) { + throw new GitUpstreamError(buildUpstreamErrorMessage('push')) + } + throw err + } + } + + /** + * Pull changes from a remote. + * + * @param path Repository path. + * @param opts Pull options. + * @returns Command result from the command runner. + */ + async pull(path: string, opts?: GitPullOpts): Promise { + const { remote, branch, username, password, ...rest } = opts ?? {} + if (password && !username) { + throw new InvalidArgumentError( + 'Username is required when using a password or token for git pull.' + ) + } + + if (!remote && !branch) { + const hasUpstream = await this.hasUpstream(path, rest) + if (!hasUpstream) { + throw new GitUpstreamError(buildUpstreamErrorMessage('pull')) + } + } + + const buildArgs = (remoteName?: string) => { + const args = ['pull'] + const targetRemote = remoteName ?? remote + if (targetRemote) { + args.push(targetRemote) + } + if (branch) { + args.push(branch) + } + return args + } + + if (username && password) { + const remoteName = await this.resolveRemoteName(path, remote, rest) + return this.withRemoteCredentials( + path, + remoteName, + username, + password, + rest, + () => this.runGit(buildArgs(remoteName), path, rest) + ) + } + + try { + return await this.runGit(buildArgs(), path, rest) + } catch (err) { + if (isAuthFailure(err)) { + throw new GitAuthError( + buildAuthErrorMessage('pull', Boolean(username) && !password) + ) + } + if (isMissingUpstream(err)) { + throw new GitUpstreamError(buildUpstreamErrorMessage('pull')) + } + throw err + } + } + + /** + * Set a git config value. + * + * Use `scope: "local"` together with `path` to configure a specific repository. + * + * @param key Git config key (for example, `"pull.rebase"`). + * @param value Git config value. + * @param opts Config options. + * @returns Command result from the command runner. + */ + async setConfig( + key: string, + value: string, + opts?: GitConfigOpts + ): Promise { + if (!key) { + throw new InvalidArgumentError('Git config key is required.') + } + + const scope = opts?.scope ?? 'global' + const scopeFlag = getScopeFlag(scope) + const repoPath = getRepoPathForScope(scope, opts?.path) + + return this.runGit(['config', scopeFlag, key, value], repoPath, opts) + } + + /** + * Get a git config value. + * + * Returns `undefined` when the key is not set in the requested scope. + * + * @param key Git config key (for example, `"pull.rebase"`). + * @param opts Config options. + * @returns The config value if present. + */ + async getConfig( + key: string, + opts?: GitConfigOpts + ): Promise { + if (!key) { + throw new InvalidArgumentError('Git config key is required.') + } + + const scope = opts?.scope ?? 'global' + const scopeFlag = getScopeFlag(scope) + const repoPath = getRepoPathForScope(scope, opts?.path) + const cmd = `${buildGitCommand(['config', scopeFlag, '--get', key], repoPath)} || true` + const result = await this.runShell(cmd, opts) + const trimmed = result.stdout.trim() + return trimmed.length > 0 ? trimmed : undefined + } + + /** + * Dangerously authenticate git globally via the credential helper. + * + * This persists credentials in the credential store. + * Prefer short-lived credentials when possible. + * + * @param opts Authentication options. + * @returns Command result from the command runner. + */ + async dangerouslyAuthenticate( + opts: GitDangerouslyAuthenticateOpts + ): Promise { + const { username, password, host, protocol, ...rest } = opts + + if (!username || !password) { + throw new InvalidArgumentError( + 'Both username and password are required to authenticate git.' + ) + } + + const targetHost = (host ?? 'github.com').trim() + const targetProtocol = (protocol ?? 'https').trim() + const credentialInput = [ + `protocol=${targetProtocol}`, + `host=${targetHost}`, + `username=${username}`, + `password=${password}`, + '', + '', + ].join('\n') + + await this.runGit( + ['config', '--global', 'credential.helper', 'store'], + undefined, + rest + ) + + const approveCmd = `printf %s ${shellEscape(credentialInput)} | ${buildGitCommand( + ['credential', 'approve'] + )}` + + return this.runShell(approveCmd, rest) + } + + /** + * Configure git user name and email. + * + * @param name Git user name. + * @param email Git user email. + * @param opts Config options. + * @returns Command result from the command runner. + */ + async configureUser( + name: string, + email: string, + opts?: GitConfigOpts + ): Promise { + if (!name || !email) { + throw new InvalidArgumentError('Both name and email are required.') + } + + const scope = opts?.scope ?? 'global' + const configOpts = { ...opts, scope } + + await this.setConfig('user.name', name, configOpts) + return this.setConfig('user.email', email, configOpts) + } + + /** + * Build and execute a git command inside the sandbox. + * + * @param args Git arguments to pass to the git binary. + * @param repoPath Repository path used with `git -C`, if provided. + * @param opts Command execution options. + * @returns Command result from the command runner. + */ + private async runGit( + args: string[], + repoPath?: string, + opts?: GitRequestOpts + ): Promise { + const { envs, ...rest } = opts ?? {} + const cmd = buildGitCommand(args, repoPath) + const mergedEnvs = { ...DEFAULT_GIT_ENV, ...(envs ?? {}) } + + return this.commands.run(cmd, { + ...rest, + envs: mergedEnvs, + }) + } + + /** + * Execute a raw shell command while applying default git environment variables. + * + Note: We can liekly just modify runGit later to allow appending commands to the git but for now it's separate. + */ + private async runShell( + cmd: string, + opts?: GitRequestOpts + ): Promise { + const { envs, ...rest } = opts ?? {} + const mergedEnvs = { ...DEFAULT_GIT_ENV, ...(envs ?? {}) } + + return this.commands.run(cmd, { + ...rest, + envs: mergedEnvs, + }) + } + + private async getRemoteUrl( + path: string, + remote: string, + opts?: GitRequestOpts + ): Promise { + const result = await this.runGit(['remote', 'get-url', remote], path, opts) + const url = result.stdout.trim() + if (!url) { + throw new InvalidArgumentError( + `Remote "${remote}" URL not found in repository.` + ) + } + return url + } + + private async resolveRemoteName( + path: string, + remote: string | undefined, + opts?: GitRequestOpts + ): Promise { + if (remote) { + return remote + } + + const result = await this.runGit(['remote'], path, opts) + const remotes = result.stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + + if (remotes.length === 1) { + return remotes[0] + } + + throw new InvalidArgumentError( + 'Remote is required when using username/password and the repository has multiple remotes.' + ) + } + + private async withRemoteCredentials( + path: string, + remote: string, + username: string, + password: string, + opts: GitRequestOpts | undefined, + operation: () => Promise + ): Promise { + const originalUrl = await this.getRemoteUrl(path, remote, opts) + const credentialUrl = withCredentials(originalUrl, username, password) + + await this.runGit(['remote', 'set-url', remote, credentialUrl], path, opts) + + let result: T | undefined + let operationError: unknown + try { + result = await operation() + } catch (err) { + operationError = err + } + + let restoreError: unknown + try { + await this.runGit(['remote', 'set-url', remote, originalUrl], path, opts) + } catch (err) { + restoreError = err + } + + if (operationError) { + throw operationError + } + if (restoreError) { + throw restoreError + } + + return result as T + } + + private async hasUpstream( + path: string, + opts?: GitRequestOpts + ): Promise { + try { + const result = await this.runGit( + ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], + path, + opts + ) + return result.stdout.trim().length > 0 + } catch { + return false + } + } +} + +export type { + GitBranches, + GitConfigScope, + GitFileStatus, + GitStatus, +} from './utils' diff --git a/packages/js-sdk/src/sandbox/git/utils.ts b/packages/js-sdk/src/sandbox/git/utils.ts new file mode 100644 index 0000000000..ad60018943 --- /dev/null +++ b/packages/js-sdk/src/sandbox/git/utils.ts @@ -0,0 +1,595 @@ +import { InvalidArgumentError } from '../../errors' +import { CommandExitError } from '../commands/commandHandle' + +/** + * Parsed git status entry for a file. + */ +export interface GitFileStatus { + /** + * Path relative to the repository root. + */ + name: string + /** + * Normalized status string (for example, `"modified"` or `"added"`). + */ + status: GitStatusLabel + /** + * Index status character from porcelain output. + */ + indexStatus: string + /** + * Working tree status character from porcelain output. + */ + workingTreeStatus: string + /** + * Whether the change is staged. + */ + staged: boolean + /** + * Original path when the file was renamed. + */ + renamedFrom?: string +} + +/** + * Supported normalized git status labels. + */ +export type GitStatusLabel = + | 'conflict' + | 'renamed' + | 'copied' + | 'deleted' + | 'added' + | 'modified' + | 'typechange' + | 'untracked' + | 'unknown' + +/** + * Scope for git config operations. + */ +export type GitConfigScope = 'global' | 'local' | 'system' + +/** + * Parsed git repository status. + */ +export interface GitStatus { + /** + * Current branch name, if available. + */ + currentBranch?: string + /** + * Upstream branch name, if available. + */ + upstream?: string + /** + * Number of commits the branch is ahead of upstream. + */ + ahead: number + /** + * Number of commits the branch is behind upstream. + */ + behind: number + /** + * Whether HEAD is detached. + */ + detached: boolean + /** + * List of file status entries. + */ + fileStatus: GitFileStatus[] + /** + * Whether the repository has no tracked or untracked file changes. + */ + isClean: boolean + /** + * Whether the repository has any tracked or untracked file changes. + */ + hasChanges: boolean + /** + * Whether there are staged changes. + */ + hasStaged: boolean + /** + * Whether there are untracked files. + */ + hasUntracked: boolean + /** + * Whether there are merge conflicts. + */ + hasConflicts: boolean + /** + * Total number of changed files. + */ + totalCount: number + /** + * Number of files with staged changes. + */ + stagedCount: number + /** + * Number of files with unstaged changes. + */ + unstagedCount: number + /** + * Number of untracked files. + */ + untrackedCount: number + /** + * Number of files with merge conflicts. + */ + conflictCount: number +} + +/** + * Parsed git branch list. + */ +export interface GitBranches { + /** + * List of branch names. + */ + branches: string[] + /** + * Current branch name, if available. + */ + currentBranch?: string +} + +/** + * Escape a string for safe use in a shell command. + * + * This uses single-quoted shell escaping and safely handles embedded single quotes. + */ +export function shellEscape(value: string): string { + return `'${value.replace(/'/g, "'\"'\"'")}'` +} + +/** + * Add HTTP(S) credentials to a Git URL. + * + * @param url Git repository URL. + * @param username Username for HTTP(S) authentication. + * @param password Password or token for HTTP(S) authentication. + * @returns URL with embedded credentials. + */ +export function withCredentials( + url: string, + username?: string, + password?: string +): string { + if (!username && !password) { + return url + } + + if (!username || !password) { + throw new InvalidArgumentError( + 'Both username and password are required when using Git credentials.' + ) + } + + let parsed: URL + try { + parsed = new URL(url) + } catch { + throw new InvalidArgumentError(`Invalid Git URL: ${url}`) + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new InvalidArgumentError( + 'Only http(s) Git URLs support username/password credentials.' + ) + } + + parsed.username = username + parsed.password = password + + return parsed.toString() +} + +/** + * Strip HTTP(S) credentials from a Git URL. + * + * @param url Git repository URL. + * @returns URL without embedded credentials. + */ +export function stripCredentials(url: string): string { + let parsed: URL + try { + parsed = new URL(url) + } catch { + return url + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return url + } + + if (!parsed.username && !parsed.password) { + return url + } + + parsed.username = '' + parsed.password = '' + return parsed.toString() +} + +/** + * Derive the default repository directory name from a Git URL. + * + * @param url Git repository URL. + * @returns Repository directory name, if it can be determined. + */ +export function deriveRepoDirFromUrl(url: string): string | undefined { + let parsed: URL + try { + parsed = new URL(url) + } catch { + return undefined + } + + const trimmedPath = parsed.pathname.replace(/\/+$/, '') + const lastSegment = trimmedPath.split('/').pop() + if (!lastSegment) { + return undefined + } + + return lastSegment.endsWith('.git') ? lastSegment.slice(0, -4) : lastSegment +} + +/** + * Build a shell-safe git command string. + * + * @param args Git command arguments. + * @param repoPath Repository path for `git -C`, if provided. + * @returns Shell-safe git command. + */ +export function buildGitCommand(args: string[], repoPath?: string): string { + const parts = ['git'] + if (repoPath) { + parts.push('-C', repoPath) + } + parts.push(...args) + + return parts.map((part) => shellEscape(part)).join(' ') +} + +type GitPushArgsOptions = { + remote?: string + branch?: string + setUpstream: boolean +} + +export function buildPushArgs( + remoteName: string | undefined, + opts: GitPushArgsOptions +): string[] { + const { remote, branch, setUpstream } = opts + const args = ['push'] + const targetRemote = remoteName ?? remote + if (setUpstream && targetRemote) { + args.push('--set-upstream') + } + if (targetRemote) { + args.push(targetRemote) + } + if (branch) { + args.push(branch) + } + return args +} + +function parseAheadBehind(segment?: string): { ahead: number; behind: number } { + if (!segment) { + return { ahead: 0, behind: 0 } + } + + let ahead = 0 + let behind = 0 + + if (segment.includes('ahead')) { + try { + ahead = Number.parseInt( + segment.split('ahead')[1].split(',')[0].trim(), + 10 + ) + } catch { + ahead = 0 + } + } + + if (segment.includes('behind')) { + try { + behind = Number.parseInt( + segment.split('behind')[1].split(',')[0].trim(), + 10 + ) + } catch { + behind = 0 + } + } + + return { ahead, behind } +} + +function normalizeBranchName(name: string): string { + if (name.startsWith('HEAD (detached at ')) { + return name.replace('HEAD (detached at ', '').replace(/\)$/, '') + } + + return name + .replace('HEAD (no branch)', 'HEAD') + .replace('No commits yet on ', '') + .replace('Initial commit on ', '') +} + +function deriveStatus( + indexStatus: string, + workingStatus: string +): GitStatusLabel { + const statuses = new Set([indexStatus, workingStatus]) + + if (statuses.has('U')) return 'conflict' + if (statuses.has('R')) return 'renamed' + if (statuses.has('C')) return 'copied' + if (statuses.has('D')) return 'deleted' + if (statuses.has('A')) return 'added' + if (statuses.has('M')) return 'modified' + if (statuses.has('T')) return 'typechange' + if (statuses.has('?')) return 'untracked' + + return 'unknown' +} + +/** + * Parse `git status --porcelain=1 -b` output into a structured object. + * + * @param output Git status output. + * @returns Parsed {@link GitStatus}. + */ +export function parseGitStatus(output: string): GitStatus { + const lines = output + .split('\n') + .map((line) => line.replace(/\r$/, '')) + .filter((line) => line.trim().length > 0) + + let currentBranch: string | undefined + let upstream: string | undefined + let ahead = 0 + let behind = 0 + let detached = false + const fileStatus: GitFileStatus[] = [] + + if (lines.length === 0) { + return { + currentBranch, + upstream, + ahead, + behind, + detached, + fileStatus, + isClean: true, + hasChanges: false, + hasStaged: false, + hasUntracked: false, + hasConflicts: false, + totalCount: 0, + stagedCount: 0, + unstagedCount: 0, + untrackedCount: 0, + conflictCount: 0, + } + } + + const branchLine = lines[0] + if (branchLine.startsWith('## ')) { + const branchInfo = branchLine.slice(3) + const aheadStart = branchInfo.indexOf(' [') + const branchPart = + aheadStart === -1 ? branchInfo : branchInfo.slice(0, aheadStart) + const aheadPart = + aheadStart === -1 ? undefined : branchInfo.slice(aheadStart + 2, -1) + const normalizedBranch = normalizeBranchName(branchPart) + const rawBranch = branchPart + const isDetached = + rawBranch.startsWith('HEAD (detached at ') || + rawBranch.includes('detached') + + if (isDetached || normalizedBranch.startsWith('HEAD')) { + detached = true + } else if (normalizedBranch.includes('...')) { + const [branch, upstreamBranch] = normalizedBranch.split('...') + currentBranch = branch || undefined + upstream = upstreamBranch || undefined + } else { + currentBranch = normalizedBranch || undefined + } + + const aheadBehind = parseAheadBehind(aheadPart) + ahead = aheadBehind.ahead + behind = aheadBehind.behind + } + + for (const line of lines.slice(1)) { + if (line.startsWith('?? ')) { + const name = line.slice(3) + fileStatus.push({ + name, + status: 'untracked', + indexStatus: '?', + workingTreeStatus: '?', + staged: false, + }) + continue + } + + if (line.length < 3) { + continue + } + + const indexStatus = line[0] + const workingTreeStatus = line[1] + const path = line.slice(3) + + let renamedFrom: string | undefined + let name = path + + if (path.includes(' -> ')) { + const parts = path.split(' -> ') + renamedFrom = parts[0] + name = parts.slice(1).join(' -> ') + } + + fileStatus.push({ + name, + status: deriveStatus(indexStatus, workingTreeStatus), + indexStatus, + workingTreeStatus, + staged: indexStatus !== ' ' && indexStatus !== '?', + ...(renamedFrom ? { renamedFrom } : {}), + }) + } + + const totalCount = fileStatus.length + const stagedCount = fileStatus.filter((item) => item.staged).length + const untrackedCount = fileStatus.filter( + (item) => item.status === 'untracked' + ).length + const conflictCount = fileStatus.filter( + (item) => item.status === 'conflict' + ).length + const unstagedCount = totalCount - stagedCount + + return { + currentBranch, + upstream, + ahead, + behind, + detached, + fileStatus, + isClean: totalCount === 0, + hasChanges: totalCount > 0, + hasStaged: stagedCount > 0, + hasUntracked: untrackedCount > 0, + hasConflicts: conflictCount > 0, + totalCount, + stagedCount, + unstagedCount, + untrackedCount, + conflictCount, + } +} + +/** + * Parse `git branch --format=%(refname:short)\t%(HEAD)` output. + * + * @param output Git branch output. + * @returns Parsed {@link GitBranches}. + */ +export function parseGitBranches(output: string): GitBranches { + const branches: string[] = [] + let currentBranch: string | undefined + + const lines = output + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) + + for (const line of lines) { + const parts = line.split('\t') + const name = parts[0] + branches.push(name) + if (parts.length > 1 && parts[1] === '*') { + currentBranch = name + } + } + + return { branches, currentBranch } +} + +export function isAuthFailure(err: unknown): boolean { + if (!(err instanceof CommandExitError)) { + return false + } + + const message = `${err.stderr}\n${err.stdout}`.toLowerCase() + const authSnippets = [ + 'authentication failed', + 'terminal prompts disabled', + 'could not read username', + 'invalid username or password', + 'access denied', + 'permission denied', + 'not authorized', + ] + + return authSnippets.some((snippet) => message.includes(snippet)) +} + +export function getScopeFlag(scope: GitConfigScope): `--${GitConfigScope}` { + if (scope !== 'global' && scope !== 'local' && scope !== 'system') { + throw new InvalidArgumentError( + 'Git config scope must be one of: global, local, system.' + ) + } + return `--${scope}` +} + +export function isMissingUpstream(err: unknown): boolean { + if (!(err instanceof CommandExitError)) { + return false + } + + const message = `${err.stderr}\n${err.stdout}`.toLowerCase() + const upstreamSnippets = [ + 'has no upstream branch', + 'no upstream branch', + 'no upstream configured', + 'no tracking information for the current branch', + 'no tracking information', + 'set the remote as upstream', + 'set the upstream branch', + 'please specify which branch you want to merge with', + ] + + return upstreamSnippets.some((snippet) => message.includes(snippet)) +} + +export function buildAuthErrorMessage( + action: 'clone' | 'push' | 'pull', + missingPassword: boolean +): string { + if (missingPassword) { + return `Git ${action} requires a password/token for private repositories.` + } + return `Git ${action} requires credentials for private repositories.` +} + +export function buildUpstreamErrorMessage(action: 'push' | 'pull'): string { + if (action === 'push') { + return ( + 'Git push failed because no upstream branch is configured. ' + + 'Set upstream once with { setUpstream: true } (and optional remote/branch), ' + + 'or pass remote and branch explicitly.' + ) + } + + return ( + 'Git pull failed because no upstream branch is configured. ' + + 'Pass remote and branch explicitly, or set upstream once (push with { setUpstream: true } ' + + 'or run: git branch --set-upstream-to=origin/ ).' + ) +} + +export function getRepoPathForScope( + scope: GitConfigScope, + path?: string +): string | undefined { + if (scope !== 'local') { + return undefined + } + if (!path) { + throw new InvalidArgumentError( + 'A repository path is required when using scope "local".' + ) + } + return path +} diff --git a/packages/js-sdk/src/sandbox/index.ts b/packages/js-sdk/src/sandbox/index.ts index b54e090edf..1865cc1985 100644 --- a/packages/js-sdk/src/sandbox/index.ts +++ b/packages/js-sdk/src/sandbox/index.ts @@ -11,6 +11,7 @@ import { EnvdApiClient, handleEnvdApiError } from '../envd/api' import { createRpcLogger } from '../logs' import { Commands, Pty } from './commands' import { Filesystem } from './filesystem' +import { Git } from './git' import { SandboxOpts, SandboxConnectOpts, @@ -48,6 +49,7 @@ export interface SandboxUrlOpts { * - Access Linux OS * - Create, list, and delete files and directories * - Run commands + * - Run git operations * - Run isolated code * - Access the internet * @@ -79,6 +81,10 @@ export class Sandbox extends SandboxApi { * Module for interacting with the sandbox pseudo-terminals */ readonly pty: Pty + /** + * Module for running git operations in the sandbox + */ + readonly git: Git /** * Unique identifier of the sandbox. @@ -199,6 +205,7 @@ export class Sandbox extends SandboxApi { this.pty = new Pty(rpcTransport, this.connectionConfig, { version: opts.envdVersion, }) + this.git = new Git(this.commands) } /** diff --git a/packages/js-sdk/tests/sandbox/git/add.test.ts b/packages/js-sdk/tests/sandbox/git/add.test.ts new file mode 100644 index 0000000000..4336c39e80 --- /dev/null +++ b/packages/js-sdk/tests/sandbox/git/add.test.ts @@ -0,0 +1,24 @@ +import { expect } from 'vitest' + +import { sandboxTest } from '../../setup.js' +import { cleanupBaseDir, createBaseDir, createRepo } from './helpers.js' + +sandboxTest('git add stages files', async ({ sandbox }) => { + const baseDir = await createBaseDir(sandbox) + + try { + const repoPath = await createRepo(sandbox, baseDir) + await sandbox.files.write(`${repoPath}/README.md`, 'hello\n') + + await sandbox.git.add(repoPath) + + const status = await sandbox.git.status(repoPath) + const entry = status.fileStatus.find( + (file: any) => file.name === 'README.md' + ) + expect(entry?.status).toBe('added') + expect(entry?.staged).toBe(true) + } finally { + await cleanupBaseDir(sandbox, baseDir) + } +}) diff --git a/packages/js-sdk/tests/sandbox/git/branches.test.ts b/packages/js-sdk/tests/sandbox/git/branches.test.ts new file mode 100644 index 0000000000..f3ed3286e7 --- /dev/null +++ b/packages/js-sdk/tests/sandbox/git/branches.test.ts @@ -0,0 +1,82 @@ +import { expect } from 'vitest' + +import { sandboxTest } from '../../setup.js' +import { + cleanupBaseDir, + createBaseDir, + createRepoWithCommit, +} from './helpers.js' + +sandboxTest('git branches lists current and feature', async ({ sandbox }) => { + const baseDir = await createBaseDir(sandbox) + + try { + const repoPath = await createRepoWithCommit(sandbox, baseDir) + await sandbox.commands.run(`git -C "${repoPath}" branch feature`) + + const branches = await sandbox.git.branches(repoPath) + expect(branches.currentBranch).toBe('main') + expect(branches.branches).toContain('main') + expect(branches.branches).toContain('feature') + } finally { + await cleanupBaseDir(sandbox, baseDir) + } +}) + +sandboxTest('git checkoutBranch switches branch', async ({ sandbox }) => { + const baseDir = await createBaseDir(sandbox) + + try { + const repoPath = await createRepoWithCommit(sandbox, baseDir) + await sandbox.commands.run(`git -C "${repoPath}" branch feature`) + + await sandbox.git.checkoutBranch(repoPath, 'feature') + + const head = ( + await sandbox.commands.run( + `git -C "${repoPath}" rev-parse --abbrev-ref HEAD` + ) + ).stdout.trim() + expect(head).toBe('feature') + } finally { + await cleanupBaseDir(sandbox, baseDir) + } +}) + +sandboxTest( + 'git createBranch creates and checks out branch', + async ({ sandbox }) => { + const baseDir = await createBaseDir(sandbox) + + try { + const repoPath = await createRepoWithCommit(sandbox, baseDir) + await sandbox.git.createBranch(repoPath, 'feature') + + const branches = await sandbox.git.branches(repoPath) + expect(branches.branches).toContain('feature') + expect(branches.currentBranch).toBe('feature') + } finally { + await cleanupBaseDir(sandbox, baseDir) + } + } +) + +sandboxTest('git deleteBranch removes branch', async ({ sandbox }) => { + const baseDir = await createBaseDir(sandbox) + + try { + const repoPath = await createRepoWithCommit(sandbox, baseDir) + await sandbox.commands.run(`git -C "${repoPath}" branch feature`) + + await sandbox.git.deleteBranch(repoPath, 'feature') + + const branch = ( + await sandbox.commands.run(`git -C "${repoPath}" branch --list feature`) + ).stdout.trim() + const branches = await sandbox.git.branches(repoPath) + expect(branch).toBe('') + expect(branches.branches).not.toContain('feature') + } finally { + await cleanupBaseDir(sandbox, baseDir) + } +}) diff --git a/packages/js-sdk/tests/sandbox/git/clone.test.ts b/packages/js-sdk/tests/sandbox/git/clone.test.ts new file mode 100644 index 0000000000..d09cac8e47 --- /dev/null +++ b/packages/js-sdk/tests/sandbox/git/clone.test.ts @@ -0,0 +1,35 @@ +import { expect } from 'vitest' + +import { sandboxTest } from '../../setup.js' +import { + cleanupBaseDir, + createBaseDir, + createRepoWithCommit, + startGitDaemon, +} from './helpers.js' + +sandboxTest('git clone fetches repo', async ({ sandbox }) => { + const baseDir = await createBaseDir(sandbox) + + try { + const repoPath = await createRepoWithCommit(sandbox, baseDir) + const daemon = await startGitDaemon(sandbox, baseDir) + const clonePath = `${baseDir}/clone` + + try { + await sandbox.git.remoteAdd(repoPath, 'origin', daemon.remoteUrl) + await sandbox.git.push(repoPath, { + remote: 'origin', + branch: 'main', + }) + + await sandbox.git.clone(daemon.remoteUrl, { path: clonePath }) + const contents = await sandbox.files.read(`${clonePath}/README.md`) + expect(contents).toContain('hello') + } finally { + await daemon.handle.kill() + } + } finally { + await cleanupBaseDir(sandbox, baseDir) + } +}) diff --git a/packages/js-sdk/tests/sandbox/git/commit.test.ts b/packages/js-sdk/tests/sandbox/git/commit.test.ts new file mode 100644 index 0000000000..5e973028a3 --- /dev/null +++ b/packages/js-sdk/tests/sandbox/git/commit.test.ts @@ -0,0 +1,66 @@ +import { expect } from 'vitest' + +import { sandboxTest } from '../../setup.js' +import { + AUTHOR_EMAIL, + AUTHOR_NAME, + cleanupBaseDir, + createBaseDir, + createRepo, +} from './helpers.js' + +sandboxTest('git commit creates commit', async ({ sandbox }) => { + const baseDir = await createBaseDir(sandbox) + + try { + const repoPath = await createRepo(sandbox, baseDir) + await sandbox.files.write(`${repoPath}/README.md`, 'hello\n') + await sandbox.git.add(repoPath) + + await sandbox.git.commit(repoPath, 'Initial commit', { + authorName: AUTHOR_NAME, + authorEmail: AUTHOR_EMAIL, + }) + + const message = ( + await sandbox.commands.run(`git -C "${repoPath}" log -1 --pretty=%B`) + ).stdout.trim() + expect(message).toBe('Initial commit') + } finally { + await cleanupBaseDir(sandbox, baseDir) + } +}) + +sandboxTest( + 'git commit uses config for missing author fields', + async ({ sandbox }) => { + const baseDir = await createBaseDir(sandbox) + + try { + const repoPath = await createRepo(sandbox, baseDir) + await sandbox.commands.run( + `git -C "${repoPath}" config --local user.email "${AUTHOR_EMAIL}"` + ) + + await sandbox.files.write(`${repoPath}/README.md`, 'hello\n') + await sandbox.git.add(repoPath) + + const overrideName = 'Override Bot' + await sandbox.git.commit(repoPath, 'Partial author commit', { + authorName: overrideName, + }) + + const authorName = ( + await sandbox.commands.run(`git -C "${repoPath}" log -1 --pretty=%an`) + ).stdout.trim() + const authorEmail = ( + await sandbox.commands.run(`git -C "${repoPath}" log -1 --pretty=%ae`) + ).stdout.trim() + + expect(authorName).toBe(overrideName) + expect(authorEmail).toBe(AUTHOR_EMAIL) + } finally { + await cleanupBaseDir(sandbox, baseDir) + } + } +) diff --git a/packages/js-sdk/tests/sandbox/git/config.test.ts b/packages/js-sdk/tests/sandbox/git/config.test.ts new file mode 100644 index 0000000000..41707150e9 --- /dev/null +++ b/packages/js-sdk/tests/sandbox/git/config.test.ts @@ -0,0 +1,87 @@ +import { expect } from 'vitest' + +import { sandboxTest } from '../../setup.js' +import { + AUTHOR_EMAIL, + AUTHOR_NAME, + cleanupBaseDir, + createBaseDir, + createRepo, +} from './helpers.js' + +sandboxTest('git getConfig reads local config', async ({ sandbox }) => { + const baseDir = await createBaseDir(sandbox) + + try { + const repoPath = await createRepo(sandbox, baseDir) + await sandbox.commands.run( + `git -C "${repoPath}" config --local pull.rebase true` + ) + + const value = await sandbox.git.getConfig('pull.rebase', { + scope: 'local', + path: repoPath, + }) + const commandValue = ( + await sandbox.commands.run( + `git -C "${repoPath}" config --local --get pull.rebase` + ) + ).stdout.trim() + expect(value).toBe('true') + expect(commandValue).toBe('true') + } finally { + await cleanupBaseDir(sandbox, baseDir) + } +}) + +sandboxTest('git setConfig updates local config', async ({ sandbox }) => { + const baseDir = await createBaseDir(sandbox) + + try { + const repoPath = await createRepo(sandbox, baseDir) + + await sandbox.git.setConfig('pull.rebase', 'true', { + scope: 'local', + path: repoPath, + }) + + const value = ( + await sandbox.commands.run( + `git -C "${repoPath}" config --local --get pull.rebase` + ) + ).stdout.trim() + const configuredValue = await sandbox.git.getConfig('pull.rebase', { + scope: 'local', + path: repoPath, + }) + expect(value).toBe('true') + expect(configuredValue).toBe('true') + } finally { + await cleanupBaseDir(sandbox, baseDir) + } +}) + +sandboxTest( + 'git configureUser sets global user config', + async ({ sandbox }) => { + await sandbox.git.configureUser(AUTHOR_NAME, AUTHOR_EMAIL) + + const name = ( + await sandbox.commands.run('git config --global --get user.name') + ).stdout.trim() + const email = ( + await sandbox.commands.run('git config --global --get user.email') + ).stdout.trim() + const configuredName = await sandbox.git.getConfig('user.name', { + scope: 'global', + }) + const configuredEmail = await sandbox.git.getConfig('user.email', { + scope: 'global', + }) + + expect(name).toBe(AUTHOR_NAME) + expect(email).toBe(AUTHOR_EMAIL) + expect(configuredName).toBe(AUTHOR_NAME) + expect(configuredEmail).toBe(AUTHOR_EMAIL) + } +) diff --git a/packages/js-sdk/tests/sandbox/git/dangerouslyAuthenticate.test.ts b/packages/js-sdk/tests/sandbox/git/dangerouslyAuthenticate.test.ts new file mode 100644 index 0000000000..1c6c382255 --- /dev/null +++ b/packages/js-sdk/tests/sandbox/git/dangerouslyAuthenticate.test.ts @@ -0,0 +1,22 @@ +import { expect } from 'vitest' + +import { sandboxTest } from '../../setup.js' +import { HOST, PASSWORD, PROTOCOL, USERNAME } from './helpers.js' + +sandboxTest('git dangerouslyAuthenticate sets helper', async ({ sandbox }) => { + await sandbox.git.dangerouslyAuthenticate({ + username: USERNAME, + password: PASSWORD, + host: HOST, + protocol: PROTOCOL, + }) + + const helper = ( + await sandbox.commands.run('git config --global --get credential.helper') + ).stdout.trim() + const configuredHelper = await sandbox.git.getConfig('credential.helper', { + scope: 'global', + }) + expect(helper).toBe('store') + expect(configuredHelper).toBe('store') +}) diff --git a/packages/js-sdk/tests/sandbox/git/helpers.ts b/packages/js-sdk/tests/sandbox/git/helpers.ts new file mode 100644 index 0000000000..797682f05b --- /dev/null +++ b/packages/js-sdk/tests/sandbox/git/helpers.ts @@ -0,0 +1,57 @@ +import { randomUUID } from 'node:crypto' + +export const AUTHOR_NAME = 'Sandbox Bot' +export const AUTHOR_EMAIL = 'sandbox@example.com' +export const USERNAME = 'git' +export const PASSWORD = 'token' +export const HOST = 'example.com' +export const PROTOCOL = 'https' + +const BASE_DIR = '/tmp/test-git' + +export async function createBaseDir(sandbox: any) { + const baseDir = `${BASE_DIR}/${randomUUID()}` + await sandbox.commands.run(`rm -rf "${baseDir}" && mkdir -p "${baseDir}"`) + return baseDir +} + +export async function cleanupBaseDir(sandbox: any, baseDir: string) { + await sandbox.commands.run(`rm -rf "${baseDir}"`) +} + +export async function createRepo(sandbox: any, baseDir: string) { + const repoPath = `${baseDir}/repo` + await sandbox.git.init(repoPath, { initialBranch: 'main' }) + return repoPath +} + +export async function createRepoWithCommit(sandbox: any, baseDir: string) { + const repoPath = await createRepo(sandbox, baseDir) + await sandbox.files.write(`${repoPath}/README.md`, 'hello\n') + await sandbox.git.add(repoPath) + await sandbox.git.commit(repoPath, 'Initial commit', { + authorName: AUTHOR_NAME, + authorEmail: AUTHOR_EMAIL, + }) + return repoPath +} + +export async function startGitDaemon(sandbox: any, baseDir: string) { + const remotePath = `${baseDir}/remote.git` + await sandbox.commands.run( + `git init --bare --initial-branch=main "${remotePath}"` + ) + const port = 9418 + Math.floor(Math.random() * 1000) + const handle = await sandbox.commands.run( + `git daemon --reuseaddr --base-path="${baseDir}" --export-all ` + + `--enable=receive-pack --informative-errors --listen=127.0.0.1 --port=${port}`, + { background: true } + ) + await sandbox.commands.run('sleep 1') + return { + handle, + remotePath, + remoteUrl: `git://127.0.0.1:${port}/remote.git`, + port, + } +} diff --git a/packages/js-sdk/tests/sandbox/git/init.test.ts b/packages/js-sdk/tests/sandbox/git/init.test.ts new file mode 100644 index 0000000000..dfc2949daf --- /dev/null +++ b/packages/js-sdk/tests/sandbox/git/init.test.ts @@ -0,0 +1,24 @@ +import { expect } from 'vitest' + +import { sandboxTest } from '../../setup.js' +import { cleanupBaseDir, createBaseDir } from './helpers.js' + +sandboxTest('git init', async ({ sandbox }) => { + const baseDir = await createBaseDir(sandbox) + + try { + const repoPath = `${baseDir}/repo` + + await sandbox.git.init(repoPath, { initialBranch: 'main' }) + + expect(await sandbox.files.exists(`${repoPath}/.git`)).toBe(true) + const head = ( + await sandbox.commands.run( + `git -C "${repoPath}" symbolic-ref --short HEAD` + ) + ).stdout.trim() + expect(head).toBe('main') + } finally { + await cleanupBaseDir(sandbox, baseDir) + } +}) diff --git a/packages/js-sdk/tests/sandbox/git/remote.test.ts b/packages/js-sdk/tests/sandbox/git/remote.test.ts new file mode 100644 index 0000000000..fdf6fc40de --- /dev/null +++ b/packages/js-sdk/tests/sandbox/git/remote.test.ts @@ -0,0 +1,82 @@ +import { expect } from 'vitest' + +import { sandboxTest } from '../../setup.js' +import { + cleanupBaseDir, + createBaseDir, + createRepo, + startGitDaemon, +} from './helpers.js' + +sandboxTest( + 'git remoteGet returns undefined for missing remote', + async ({ sandbox }) => { + const baseDir = await createBaseDir(sandbox) + + try { + const repoPath = await createRepo(sandbox, baseDir) + const missingUrl = await sandbox.git.remoteGet(repoPath, 'origin') + expect(missingUrl).toBeUndefined() + } finally { + await cleanupBaseDir(sandbox, baseDir) + } + } +) + +sandboxTest('git remoteAdd adds remote', async ({ sandbox }) => { + const baseDir = await createBaseDir(sandbox) + + try { + const repoPath = await createRepo(sandbox, baseDir) + const daemon = await startGitDaemon(sandbox, baseDir) + + try { + await sandbox.git.remoteAdd(repoPath, 'origin', daemon.remoteUrl) + const remoteUrl = await sandbox.git.remoteGet(repoPath, 'origin') + expect(remoteUrl).toBe(daemon.remoteUrl) + } finally { + await daemon.handle.kill() + } + } finally { + await cleanupBaseDir(sandbox, baseDir) + } +}) + +sandboxTest('git remoteAdd overwrites existing remote', async ({ sandbox }) => { + const baseDir = await createBaseDir(sandbox) + + try { + const repoPath = await createRepo(sandbox, baseDir) + const daemon = await startGitDaemon(sandbox, baseDir) + + try { + await sandbox.git.remoteAdd(repoPath, 'origin', daemon.remoteUrl) + const currentUrl = ( + await sandbox.commands.run(`git -C "${repoPath}" remote get-url origin`) + ).stdout.trim() + const currentRemote = await sandbox.git.remoteGet(repoPath, 'origin') + expect(currentUrl).toBe(daemon.remoteUrl) + expect(currentRemote).toBe(daemon.remoteUrl) + + const secondPath = `${baseDir}/remote-2.git` + await sandbox.commands.run( + `git init --bare --initial-branch=main "${secondPath}"` + ) + const secondUrl = `git://127.0.0.1:${daemon.port}/remote-2.git` + + await sandbox.git.remoteAdd(repoPath, 'origin', secondUrl, { + overwrite: true, + }) + const updatedUrl = ( + await sandbox.commands.run(`git -C "${repoPath}" remote get-url origin`) + ).stdout.trim() + const updatedRemote = await sandbox.git.remoteGet(repoPath, 'origin') + expect(updatedUrl).toBe(secondUrl) + expect(updatedRemote).toBe(secondUrl) + } finally { + await daemon.handle.kill() + } + } finally { + await cleanupBaseDir(sandbox, baseDir) + } +}) diff --git a/packages/js-sdk/tests/sandbox/git/reset.test.ts b/packages/js-sdk/tests/sandbox/git/reset.test.ts new file mode 100644 index 0000000000..daf8bcd38a --- /dev/null +++ b/packages/js-sdk/tests/sandbox/git/reset.test.ts @@ -0,0 +1,30 @@ +import { expect } from 'vitest' + +import { sandboxTest } from '../../setup.js' +import { + cleanupBaseDir, + createBaseDir, + createRepoWithCommit, +} from './helpers.js' + +sandboxTest('git reset --hard discards changes', async ({ sandbox }) => { + const baseDir = await createBaseDir(sandbox) + + try { + const repoPath = await createRepoWithCommit(sandbox, baseDir) + await sandbox.files.write(`${repoPath}/README.md`, 'changed\n') + + const status = await sandbox.git.status(repoPath) + expect(status.isClean).toBe(false) + + await sandbox.git.reset(repoPath, { mode: 'hard', target: 'HEAD' }) + + const statusAfter = await sandbox.git.status(repoPath) + expect(statusAfter.isClean).toBe(true) + + const contents = await sandbox.files.read(`${repoPath}/README.md`) + expect(contents).toBe('hello\n') + } finally { + await cleanupBaseDir(sandbox, baseDir) + } +}) diff --git a/packages/js-sdk/tests/sandbox/git/restore.test.ts b/packages/js-sdk/tests/sandbox/git/restore.test.ts new file mode 100644 index 0000000000..2d03484ab1 --- /dev/null +++ b/packages/js-sdk/tests/sandbox/git/restore.test.ts @@ -0,0 +1,58 @@ +import { expect } from 'vitest' + +import { sandboxTest } from '../../setup.js' +import { + cleanupBaseDir, + createBaseDir, + createRepoWithCommit, +} from './helpers.js' + +sandboxTest('git restore --staged unstages changes', async ({ sandbox }) => { + const baseDir = await createBaseDir(sandbox) + + try { + const repoPath = await createRepoWithCommit(sandbox, baseDir) + await sandbox.files.write(`${repoPath}/README.md`, 'changed\n') + await sandbox.git.add(repoPath, { files: ['README.md'] }) + + const status = await sandbox.git.status(repoPath) + expect(status.hasStaged).toBe(true) + + await sandbox.git.restore(repoPath, { + paths: ['README.md'], + staged: true, + worktree: false, + }) + + const statusAfter = await sandbox.git.status(repoPath) + expect(statusAfter.hasStaged).toBe(false) + expect(statusAfter.hasChanges).toBe(true) + } finally { + await cleanupBaseDir(sandbox, baseDir) + } +}) + +sandboxTest( + 'git restore discards working tree changes', + async ({ sandbox }) => { + const baseDir = await createBaseDir(sandbox) + + try { + const repoPath = await createRepoWithCommit(sandbox, baseDir) + await sandbox.files.write(`${repoPath}/README.md`, 'changed\n') + + const status = await sandbox.git.status(repoPath) + expect(status.isClean).toBe(false) + + await sandbox.git.restore(repoPath, { paths: ['README.md'] }) + + const statusAfter = await sandbox.git.status(repoPath) + expect(statusAfter.isClean).toBe(true) + + const contents = await sandbox.files.read(`${repoPath}/README.md`) + expect(contents).toBe('hello\n') + } finally { + await cleanupBaseDir(sandbox, baseDir) + } + } +) diff --git a/packages/js-sdk/tests/sandbox/git/status.test.ts b/packages/js-sdk/tests/sandbox/git/status.test.ts new file mode 100644 index 0000000000..bbae9495db --- /dev/null +++ b/packages/js-sdk/tests/sandbox/git/status.test.ts @@ -0,0 +1,99 @@ +import { expect } from 'vitest' + +import { sandboxTest } from '../../setup.js' +import { + AUTHOR_EMAIL, + AUTHOR_NAME, + cleanupBaseDir, + createBaseDir, + createRepo, +} from './helpers.js' + +sandboxTest('git status reports untracked file', async ({ sandbox }) => { + const baseDir = await createBaseDir(sandbox) + + try { + const repoPath = await createRepo(sandbox, baseDir) + await sandbox.files.write(`${repoPath}/README.md`, 'hello\n') + + const status = await sandbox.git.status(repoPath) + const entry = status.fileStatus.find( + (file: any) => file.name === 'README.md' + ) + expect(entry?.status).toBe('untracked') + expect(status.isClean).toBe(false) + expect(status.hasChanges).toBe(true) + expect(status.hasUntracked).toBe(true) + expect(status.hasStaged).toBe(false) + expect(status.hasConflicts).toBe(false) + expect(status.totalCount).toBe(1) + expect(status.stagedCount).toBe(0) + expect(status.unstagedCount).toBe(1) + expect(status.untrackedCount).toBe(1) + expect(status.conflictCount).toBe(0) + } finally { + await cleanupBaseDir(sandbox, baseDir) + } +}) + +sandboxTest( + 'git status reports added modified deleted renamed', + async ({ sandbox }) => { + const baseDir = await createBaseDir(sandbox) + + try { + const repoPath = await createRepo(sandbox, baseDir) + await sandbox.files.write(`${repoPath}/README.md`, 'hello\n') + await sandbox.files.write(`${repoPath}/DELETE.md`, 'delete me\n') + await sandbox.files.write(`${repoPath}/RENAME.md`, 'rename me\n') + await sandbox.git.add(repoPath) + await sandbox.git.commit(repoPath, 'Initial commit', { + authorName: AUTHOR_NAME, + authorEmail: AUTHOR_EMAIL, + }) + + await sandbox.files.write(`${repoPath}/README.md`, 'hello again\n') + await sandbox.files.write(`${repoPath}/NEW.md`, 'new file\n') + await sandbox.git.add(repoPath, { files: ['NEW.md'] }) + await sandbox.commands.run(`git -C "${repoPath}" rm DELETE.md`) + await sandbox.commands.run(`git -C "${repoPath}" mv RENAME.md RENAMED.md`) + + const status = await sandbox.git.status(repoPath) + + const modified = status.fileStatus.find( + (file: any) => file.name === 'README.md' + ) + const added = status.fileStatus.find( + (file: any) => file.name === 'NEW.md' + ) + const deleted = status.fileStatus.find( + (file: any) => file.name === 'DELETE.md' + ) + const renamed = status.fileStatus.find( + (file: any) => file.name === 'RENAMED.md' + ) + + expect(modified?.status).toBe('modified') + expect(modified?.staged).toBe(false) + expect(added?.status).toBe('added') + expect(added?.staged).toBe(true) + expect(deleted?.status).toBe('deleted') + expect(deleted?.staged).toBe(true) + expect(renamed?.status).toBe('renamed') + expect(renamed?.staged).toBe(true) + expect(renamed?.renamedFrom).toBe('RENAME.md') + + expect(status.hasChanges).toBe(true) + expect(status.hasStaged).toBe(true) + expect(status.hasUntracked).toBe(false) + expect(status.hasConflicts).toBe(false) + expect(status.totalCount).toBe(4) + expect(status.stagedCount).toBe(3) + expect(status.unstagedCount).toBe(1) + expect(status.untrackedCount).toBe(0) + expect(status.conflictCount).toBe(0) + } finally { + await cleanupBaseDir(sandbox, baseDir) + } + } +) diff --git a/packages/js-sdk/tests/sandbox/git/sync.test.ts b/packages/js-sdk/tests/sandbox/git/sync.test.ts new file mode 100644 index 0000000000..f51b5efad7 --- /dev/null +++ b/packages/js-sdk/tests/sandbox/git/sync.test.ts @@ -0,0 +1,116 @@ +import { expect } from 'vitest' + +import { sandboxTest } from '../../setup.js' +import { + AUTHOR_EMAIL, + AUTHOR_NAME, + cleanupBaseDir, + createBaseDir, + createRepoWithCommit, + startGitDaemon, +} from './helpers.js' + +sandboxTest('git push updates remote', async ({ sandbox }) => { + const baseDir = await createBaseDir(sandbox) + + try { + const repoPath = await createRepoWithCommit(sandbox, baseDir) + const daemon = await startGitDaemon(sandbox, baseDir) + + try { + await sandbox.git.remoteAdd(repoPath, 'origin', daemon.remoteUrl) + await sandbox.git.push(repoPath, { + remote: 'origin', + branch: 'main', + }) + + const message = ( + await sandbox.commands.run( + `git --git-dir="${daemon.remotePath}" log -1 --pretty=%B` + ) + ).stdout.trim() + expect(message).toBe('Initial commit') + } finally { + await daemon.handle.kill() + } + } finally { + await cleanupBaseDir(sandbox, baseDir) + } +}) + +sandboxTest('git push warns when no upstream', async ({ sandbox }) => { + const baseDir = await createBaseDir(sandbox) + + try { + const repoPath = await createRepoWithCommit(sandbox, baseDir) + const daemon = await startGitDaemon(sandbox, baseDir) + + try { + await sandbox.git.remoteAdd(repoPath, 'origin', daemon.remoteUrl) + + await expect( + sandbox.git.push(repoPath, { setUpstream: false }) + ).rejects.toThrow(/no upstream branch is configured/i) + } finally { + await daemon.handle.kill() + } + } finally { + await cleanupBaseDir(sandbox, baseDir) + } +}) + +sandboxTest('git pull updates clone', async ({ sandbox }) => { + const baseDir = await createBaseDir(sandbox) + + try { + const repoPath = await createRepoWithCommit(sandbox, baseDir) + const daemon = await startGitDaemon(sandbox, baseDir) + const clonePath = `${baseDir}/clone` + + try { + await sandbox.git.remoteAdd(repoPath, 'origin', daemon.remoteUrl) + await sandbox.git.push(repoPath, { + remote: 'origin', + branch: 'main', + }) + await sandbox.git.clone(daemon.remoteUrl, { path: clonePath }) + + await sandbox.files.write(`${repoPath}/README.md`, 'hello\nmore\n') + await sandbox.git.add(repoPath) + await sandbox.git.commit(repoPath, 'Update README', { + authorName: AUTHOR_NAME, + authorEmail: AUTHOR_EMAIL, + }) + await sandbox.git.push(repoPath) + + await sandbox.git.pull(clonePath) + const contents = await sandbox.files.read(`${clonePath}/README.md`) + expect(contents).toContain('more') + } finally { + await daemon.handle.kill() + } + } finally { + await cleanupBaseDir(sandbox, baseDir) + } +}) + +sandboxTest('git pull warns when no upstream', async ({ sandbox }) => { + const baseDir = await createBaseDir(sandbox) + + try { + const repoPath = await createRepoWithCommit(sandbox, baseDir) + const daemon = await startGitDaemon(sandbox, baseDir) + + try { + await sandbox.git.remoteAdd(repoPath, 'origin', daemon.remoteUrl) + + await expect(sandbox.git.pull(repoPath)).rejects.toThrow( + /no upstream branch is configured/i + ) + } finally { + await daemon.handle.kill() + } + } finally { + await cleanupBaseDir(sandbox, baseDir) + } +}) diff --git a/packages/python-sdk/e2b/__init__.py b/packages/python-sdk/e2b/__init__.py index 0890278b31..57bfe4a3fb 100644 --- a/packages/python-sdk/e2b/__init__.py +++ b/packages/python-sdk/e2b/__init__.py @@ -35,6 +35,8 @@ ) from .exceptions import ( AuthenticationException, + GitAuthException, + GitUpstreamException, BuildException, FileUploadException, InvalidArgumentException, @@ -58,6 +60,7 @@ FilesystemEvent, FilesystemEventType, ) +from .sandbox.git_utils import GitBranches, GitFileStatus, GitStatus from .sandbox.network import ALL_TRAFFIC from .sandbox.sandbox_api import ( GitHubMcpServer, @@ -110,6 +113,8 @@ "TimeoutException", "NotFoundException", "AuthenticationException", + "GitAuthException", + "GitUpstreamException", "InvalidArgumentException", "NotEnoughSpaceException", "TemplateException", @@ -122,6 +127,9 @@ "SandboxQuery", "SandboxState", "SandboxMetrics", + "GitStatus", + "GitBranches", + "GitFileStatus", # Command handle "CommandResult", "Stderr", diff --git a/packages/python-sdk/e2b/exceptions.py b/packages/python-sdk/e2b/exceptions.py index ec75a99bb8..75f5f6abf0 100644 --- a/packages/python-sdk/e2b/exceptions.py +++ b/packages/python-sdk/e2b/exceptions.py @@ -71,6 +71,22 @@ class AuthenticationException(Exception): pass +class GitAuthException(AuthenticationException): + """ + Raised when git authentication fails. + """ + + pass + + +class GitUpstreamException(SandboxException): + """ + Raised when git upstream tracking is missing. + """ + + pass + + class TemplateException(SandboxException): """ Exception raised when the template uses old envd version. It isn't compatible with the new SDK. diff --git a/packages/python-sdk/e2b/sandbox/git_utils.py b/packages/python-sdk/e2b/sandbox/git_utils.py new file mode 100644 index 0000000000..3ac044d486 --- /dev/null +++ b/packages/python-sdk/e2b/sandbox/git_utils.py @@ -0,0 +1,540 @@ +from dataclasses import dataclass +from typing import List, Optional +from urllib.parse import urlparse, urlunparse + +from e2b.exceptions import InvalidArgumentException +from e2b.sandbox.commands.command_handle import CommandExitException + + +@dataclass +class GitFileStatus: + """ + Parsed git status entry for a file. + + :param name: Path relative to the repository root + :param status: Normalized status string (e.g. "modified", "added") + :param index_status: Index status character from porcelain output + :param working_tree_status: Working tree status character from porcelain output + :param staged: Whether the change is staged + :param renamed_from: Original path when the file was renamed + """ + + name: str + status: str + index_status: str + working_tree_status: str + staged: bool + renamed_from: Optional[str] = None + + +@dataclass +class GitStatus: + """ + Parsed git repository status. + + :param current_branch: Current branch name, if available + :param upstream: Upstream branch name, if available + :param ahead: Number of commits the branch is ahead of upstream + :param behind: Number of commits the branch is behind upstream + :param detached: Whether HEAD is detached + :param file_status: List of file status entries + """ + + current_branch: Optional[str] + upstream: Optional[str] + ahead: int + behind: int + detached: bool + file_status: List[GitFileStatus] + + @property + def is_clean(self) -> bool: + """ + Return True when there are no tracked or untracked file changes. + """ + return len(self.file_status) == 0 + + @property + def has_changes(self) -> bool: + """ + Return True when there are any tracked or untracked file changes. + """ + return len(self.file_status) > 0 + + @property + def has_staged(self) -> bool: + """ + Return True when at least one file has staged changes. + """ + return any(item.staged for item in self.file_status) + + @property + def has_untracked(self) -> bool: + """ + Return True when at least one file is untracked. + """ + return any(item.status == "untracked" for item in self.file_status) + + @property + def has_conflicts(self) -> bool: + """ + Return True when at least one file is in conflict. + """ + return any(item.status == "conflict" for item in self.file_status) + + @property + def total_count(self) -> int: + """ + Return the total number of changed files. + """ + return len(self.file_status) + + @property + def staged_count(self) -> int: + """ + Return the number of files with staged changes. + """ + return sum(1 for item in self.file_status if item.staged) + + @property + def unstaged_count(self) -> int: + """ + Return the number of files with unstaged changes. + """ + return sum(1 for item in self.file_status if not item.staged) + + @property + def untracked_count(self) -> int: + """ + Return the number of untracked files. + """ + return sum(1 for item in self.file_status if item.status == "untracked") + + @property + def conflict_count(self) -> int: + """ + Return the number of files with merge conflicts. + """ + return sum(1 for item in self.file_status if item.status == "conflict") + + +@dataclass +class GitBranches: + """ + Parsed git branch list. + + :param branches: List of branch names + :param current_branch: Current branch name, if available + """ + + branches: List[str] + current_branch: Optional[str] + + +def shell_escape(value: str) -> str: + """ + Escape a string for safe use in a shell command. + + :param value: Value to escape + :return: Shell-escaped string + """ + return "'" + value.replace("'", "'\"'\"'") + "'" + + +def with_credentials(url: str, username: Optional[str], password: Optional[str]) -> str: + """ + Add HTTP(S) credentials to a Git URL. + + :param url: Git repository URL + :param username: Username for HTTP(S) authentication + :param password: Password or token for HTTP(S) authentication + :return: URL with embedded credentials + """ + if not username and not password: + return url + if not username or not password: + raise InvalidArgumentException( + "Both username and password are required when using Git credentials." + ) + + parsed = urlparse(url) + if parsed.scheme not in ("http", "https"): + raise InvalidArgumentException( + "Only http(s) Git URLs support username/password credentials." + ) + + netloc = f"{username}:{password}@{parsed.netloc}" + return urlunparse(parsed._replace(netloc=netloc)) + + +def strip_credentials(url: str) -> str: + """ + Strip HTTP(S) credentials from a Git URL. + + :param url: Git repository URL + :return: URL without embedded credentials + """ + parsed = urlparse(url) + if parsed.scheme not in ("http", "https"): + return url + if not parsed.username and not parsed.password: + return url + + host = parsed.hostname or "" + if parsed.port: + host = f"{host}:{parsed.port}" + + return urlunparse(parsed._replace(netloc=host)) + + +def derive_repo_dir_from_url(url: str) -> Optional[str]: + """ + Derive the default repository directory name from a Git URL. + + :param url: Git repository URL + :return: Repository directory name, if it can be determined + """ + parsed = urlparse(url) + if parsed.scheme not in ("http", "https"): + return None + trimmed_path = parsed.path.rstrip("/") + if not trimmed_path: + return None + last_segment = trimmed_path.split("/")[-1] + if not last_segment: + return None + return last_segment[:-4] if last_segment.endswith(".git") else last_segment + + +def build_git_command(args: List[str], repo_path: Optional[str] = None) -> str: + """ + Build a shell-safe git command string. + + :param args: Git command arguments + :param repo_path: Repository path for `git -C`, if provided + :return: Shell-safe git command + """ + parts = ["git"] + if repo_path: + parts.extend(["-C", repo_path]) + parts.extend(args) + return " ".join(shell_escape(part) for part in parts) + + +def build_push_args( + remote_name: Optional[str], + *, + remote: Optional[str], + branch: Optional[str], + set_upstream: bool, +) -> List[str]: + """ + Build arguments for a git push command. + + :param remote_name: Resolved remote name, if any + :param remote: Remote name override + :param branch: Branch name to push + :param set_upstream: Whether to set upstream tracking + :return: List of git push arguments + """ + args = ["push"] + target_remote = remote_name or remote + if set_upstream and target_remote: + args.append("--set-upstream") + if target_remote: + args.append(target_remote) + if branch: + args.append(branch) + return args + + +def is_auth_failure(err: Exception) -> bool: + """ + Check whether a git command failed due to authentication issues. + + :param err: Exception raised by a git command + :return: True when the error matches common authentication failures + """ + if not isinstance(err, CommandExitException): + return False + + message = f"{err.stderr}\n{err.stdout}".lower() + auth_snippets = [ + "authentication failed", + "terminal prompts disabled", + "could not read username", + "invalid username or password", + "access denied", + "permission denied", + "not authorized", + ] + return any(snippet in message for snippet in auth_snippets) + + +def is_missing_upstream(err: Exception) -> bool: + """ + Check whether a git command failed due to missing upstream tracking. + + :param err: Exception raised by a git command + :return: True when the error matches common upstream failures + """ + if not isinstance(err, CommandExitException): + return False + + message = f"{err.stderr}\n{err.stdout}".lower() + upstream_snippets = [ + "has no upstream branch", + "no upstream branch", + "no upstream configured", + "no tracking information for the current branch", + "no tracking information", + "set the remote as upstream", + "set the upstream branch", + "please specify which branch you want to merge with", + ] + return any(snippet in message for snippet in upstream_snippets) + + +def build_auth_error_message(action: str, missing_password: bool) -> str: + """ + Build a git authentication error message for the given action. + + :param action: Git action name + :param missing_password: Whether the password/token is missing + :return: Error message string + """ + if missing_password: + return f"Git {action} requires a password/token for private repositories." + return f"Git {action} requires credentials for private repositories." + + +def build_upstream_error_message(action: str) -> str: + """ + Build a git upstream tracking error message for the given action. + + :param action: Git action name + :return: Error message string + """ + if action == "push": + return ( + "Git push failed because no upstream branch is configured. " + "Set upstream once with set_upstream=True (and optional remote/branch), " + "or pass remote and branch explicitly." + ) + + return ( + "Git pull failed because no upstream branch is configured. " + "Pass remote and branch explicitly, or set upstream once (push with " + "set_upstream=True or run: git branch --set-upstream-to=origin/ )." + ) + + +def resolve_config_scope( + scope: Optional[str], path: Optional[str] +) -> tuple[str, Optional[str]]: + """ + Resolve a git config scope flag and repository path. + + :param scope: Requested scope ("global", "local", "system") + :param path: Repository path for local scope + :return: Tuple of (scope flag, repository path) + """ + scope_name = (scope or "global").strip().lower() + if scope_name not in {"global", "local", "system"}: + raise InvalidArgumentException( + "Git config scope must be one of: global, local, system." + ) + + if scope_name == "local": + if not path: + raise InvalidArgumentException( + "Repository path is required when scope is local." + ) + return "--local", path + + if scope_name == "system": + return "--system", None + + return "--global", None + + +def _parse_ahead_behind(segment: Optional[str]) -> tuple[int, int]: + """ + Parse the ahead/behind segment from porcelain branch info. + + :param segment: Segment text like "ahead 2, behind 1" + :return: Tuple of (ahead, behind) + """ + if not segment: + return 0, 0 + ahead = 0 + behind = 0 + if "ahead" in segment: + try: + ahead = int(segment.split("ahead")[1].split(",")[0].strip()) + except Exception: + ahead = 0 + if "behind" in segment: + try: + behind = int(segment.split("behind")[1].split(",")[0].strip()) + except Exception: + behind = 0 + return ahead, behind + + +def _normalize_branch_name(name: str) -> str: + """ + Normalize branch names from porcelain branch output. + + :param name: Raw branch name section + :return: Normalized branch name + """ + if name.startswith("HEAD (detached at "): + return name.replace("HEAD (detached at ", "").rstrip(")") + return ( + name.replace("HEAD (no branch)", "HEAD") + .replace("No commits yet on ", "") + .replace("Initial commit on ", "") + ) + + +def _derive_status(index_status: str, working_status: str) -> str: + """ + Derive a normalized status label from porcelain status characters. + + :param index_status: Index status character + :param working_status: Working tree status character + :return: Normalized status label + """ + statuses = {index_status, working_status} + if "U" in statuses: + return "conflict" + if "R" in statuses: + return "renamed" + if "C" in statuses: + return "copied" + if "D" in statuses: + return "deleted" + if "A" in statuses: + return "added" + if "M" in statuses: + return "modified" + if "T" in statuses: + return "typechange" + if "?" in statuses: + return "untracked" + return "unknown" + + +def parse_git_status(output: str) -> GitStatus: + """ + Parse `git status --porcelain=1 -b` output into a structured object. + + :param output: Git status output + :return: Parsed `GitStatus` + """ + lines = [line.rstrip() for line in output.split("\n") if line.strip()] + current_branch: Optional[str] = None + upstream: Optional[str] = None + ahead = 0 + behind = 0 + detached = False + file_status: List[GitFileStatus] = [] + + if not lines: + return GitStatus( + current_branch=current_branch, + upstream=upstream, + ahead=ahead, + behind=behind, + detached=detached, + file_status=file_status, + ) + + branch_line = lines[0] + if branch_line.startswith("## "): + branch_info = branch_line[3:] + ahead_start = branch_info.find(" [") + branch_part = branch_info if ahead_start == -1 else branch_info[:ahead_start] + ahead_part = None if ahead_start == -1 else branch_info[ahead_start + 2 : -1] + normalized_branch = _normalize_branch_name(branch_part) + raw_branch = branch_part + is_detached = raw_branch.startswith("HEAD (detached at ") or ( + "detached" in raw_branch + ) + + if is_detached or normalized_branch.startswith("HEAD"): + detached = True + elif "..." in normalized_branch: + branch, upstream_branch = normalized_branch.split("...") + current_branch = branch or None + upstream = upstream_branch or None + else: + current_branch = normalized_branch or None + + ahead, behind = _parse_ahead_behind(ahead_part) + + for line in lines[1:]: + if line.startswith("?? "): + name = line[3:] + file_status.append( + GitFileStatus( + name=name, + status="untracked", + index_status="?", + working_tree_status="?", + staged=False, + ) + ) + continue + + if len(line) < 3: + continue + index_status = line[0] + working_status = line[1] + path = line[3:] + renamed_from: Optional[str] = None + name = path + if " -> " in path: + renamed_from, name = path.split(" -> ", 1) + + file_status.append( + GitFileStatus( + name=name, + status=_derive_status(index_status, working_status), + index_status=index_status, + working_tree_status=working_status, + staged=index_status not in (" ", "?"), + renamed_from=renamed_from, + ) + ) + + return GitStatus( + current_branch=current_branch, + upstream=upstream, + ahead=ahead, + behind=behind, + detached=detached, + file_status=file_status, + ) + + +def parse_git_branches(output: str) -> GitBranches: + """ + Parse `git branch --format=%(refname:short)\t%(HEAD)` output. + + :param output: Git branch output + :return: Parsed `GitBranches` + """ + branches: List[str] = [] + current_branch: Optional[str] = None + + lines = [line.strip() for line in output.split("\n") if line.strip()] + for line in lines: + parts = line.split("\t") + name = parts[0] + branches.append(name) + if len(parts) > 1 and parts[1] == "*": + current_branch = name + + return GitBranches(branches=branches, current_branch=current_branch) diff --git a/packages/python-sdk/e2b/sandbox_async/git.py b/packages/python-sdk/e2b/sandbox_async/git.py new file mode 100644 index 0000000000..1a2d194547 --- /dev/null +++ b/packages/python-sdk/e2b/sandbox_async/git.py @@ -0,0 +1,1082 @@ +from typing import Dict, List, Optional + +from e2b.exceptions import ( + GitAuthException, + GitUpstreamException, + InvalidArgumentException, +) +from e2b.sandbox.commands.command_handle import CommandExitException +from e2b.sandbox.git_utils import ( + GitBranches, + GitStatus, + build_auth_error_message, + build_git_command, + build_push_args, + build_upstream_error_message, + is_auth_failure, + is_missing_upstream, + parse_git_branches, + parse_git_status, + resolve_config_scope, + shell_escape, + strip_credentials, + derive_repo_dir_from_url, + with_credentials, +) +from e2b.sandbox_async.commands.command import Commands + + +DEFAULT_GIT_ENV = {"GIT_TERMINAL_PROMPT": "0"} + + +class Git: + """ + Async module for running git operations in the sandbox. + """ + + def __init__(self, commands: Commands) -> None: + """ + Create a Git helper bound to the sandbox command runner. + + :param commands: Command runner used to execute git commands + """ + self._commands = commands + + async def _run_git( + self, + args: List[str], + repo_path: Optional[str], + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Build and execute a git command inside the sandbox. + + :param args: Git arguments to pass to the git binary + :param repo_path: Repository path used with `git -C`, if provided + :param envs: Extra environment variables for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + cmd = build_git_command(args, repo_path) + merged_envs = {**DEFAULT_GIT_ENV, **(envs or {})} + return await self._commands.run( + cmd, + envs=merged_envs, + user=user, + cwd=cwd, + timeout=timeout, + request_timeout=request_timeout, + ) + + async def _run_shell( + self, + cmd: str, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Execute a raw shell command while applying default git environment variables. + + :param cmd: Shell command to execute + :param envs: Extra environment variables for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + merged_envs = {**DEFAULT_GIT_ENV, **(envs or {})} + return await self._commands.run( + cmd, + envs=merged_envs, + user=user, + cwd=cwd, + timeout=timeout, + request_timeout=request_timeout, + ) + + async def _has_upstream( + self, + path: str, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ) -> bool: + try: + result = await self._run_git( + ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], + path, + envs, + user, + cwd, + timeout, + request_timeout, + ) + return bool(result.stdout.strip()) + except Exception: + return False + + async def _resolve_remote_name( + self, + path: str, + remote: Optional[str], + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ) -> str: + if remote: + return remote + + result = await self._run_git( + ["remote"], + path, + envs, + user, + cwd, + timeout, + request_timeout, + ) + remotes = [line.strip() for line in result.stdout.splitlines() if line.strip()] + if len(remotes) == 1: + return remotes[0] + + raise InvalidArgumentException( + "Remote is required when using username/password and the repository has multiple remotes." + ) + + async def _with_remote_credentials( + self, + path: str, + remote: str, + username: str, + password: str, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + operation=None, + ): + original_url = await self._get_remote_url( + path, remote, envs, user, cwd, timeout, request_timeout + ) + credential_url = with_credentials(original_url, username, password) + await self._run_git( + ["remote", "set-url", remote, credential_url], + path, + envs, + user, + cwd, + timeout, + request_timeout, + ) + + result = None + operation_error: Exception | None = None + try: + if operation is None: + raise InvalidArgumentException("Operation is required.") + result = await operation() + except Exception as err: + operation_error = err + + restore_error: Exception | None = None + try: + await self._run_git( + ["remote", "set-url", remote, original_url], + path, + envs, + user, + cwd, + timeout, + request_timeout, + ) + except Exception as err: + restore_error = err + + if operation_error: + raise operation_error + if restore_error: + raise restore_error + + return result + + async def _get_remote_url( + self, + path: str, + remote: str, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ) -> str: + result = await self._run_git( + ["remote", "get-url", remote], + path, + envs, + user, + cwd, + timeout, + request_timeout, + ) + url = result.stdout.strip() + if not url: + raise InvalidArgumentException( + f'Remote "{remote}" URL not found in repository.' + ) + return url + + async def clone( + self, + url: str, + path: Optional[str] = None, + branch: Optional[str] = None, + depth: Optional[int] = None, + username: Optional[str] = None, + password: Optional[str] = None, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + dangerously_store_credentials: bool = False, + ): + """ + Clone a git repository into the sandbox. + + :param url: Git repository URL + :param path: Destination path for the clone + :param branch: Branch to check out + :param depth: If set, perform a shallow clone with this depth + :param username: Username for HTTP(S) authentication + :param password: Password or token for HTTP(S) authentication + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :param dangerously_store_credentials: Store credentials in the cloned repository when True + :return: Command result from the command runner + """ + if password and not username: + raise InvalidArgumentException( + "Username is required when using a password or token for git clone." + ) + + async def attempt_clone( + auth_username: Optional[str], auth_password: Optional[str] + ): + clone_url = ( + with_credentials(url, auth_username, auth_password) + if auth_username and auth_password + else url + ) + sanitized_url = strip_credentials(clone_url) + should_strip = ( + not dangerously_store_credentials and sanitized_url != clone_url + ) + repo_path = ( + path if not should_strip else path or derive_repo_dir_from_url(url) + ) + if should_strip and not repo_path: + raise InvalidArgumentException( + "A destination path is required when using credentials without storing them." + ) + args = ["clone", clone_url] + if branch: + args.extend(["--branch", branch, "--single-branch"]) + if depth: + args.extend(["--depth", str(depth)]) + if path: + args.append(path) + result = await self._run_git( + args, None, envs, user, cwd, timeout, request_timeout + ) + if should_strip and repo_path: + await self._run_git( + ["remote", "set-url", "origin", sanitized_url], + repo_path, + envs, + user, + cwd, + timeout, + request_timeout, + ) + return result + + try: + return await attempt_clone(username, password) + except CommandExitException as err: + if is_auth_failure(err): + raise GitAuthException( + build_auth_error_message("clone", bool(username) and not password) + ) from err + raise + + async def init( + self, + path: str, + bare: bool = False, + initial_branch: Optional[str] = None, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Initialize a new git repository. + + :param path: Destination path for the repository + :param bare: Create a bare repository when True + :param initial_branch: Initial branch name (for example, "main") + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + args = ["init"] + if initial_branch: + args.extend(["--initial-branch", initial_branch]) + if bare: + args.append("--bare") + args.append(path) + return await self._run_git( + args, None, envs, user, cwd, timeout, request_timeout + ) + + async def remote_add( + self, + path: str, + name: str, + url: str, + fetch: bool = False, + overwrite: bool = False, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Add (or update) a remote for a repository. + + :param path: Repository path + :param name: Remote name (for example, "origin") + :param url: Remote URL + :param fetch: Fetch the remote after adding it when True + :param overwrite: Overwrite the remote URL if it already exists when True + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + if not name or not url: + raise InvalidArgumentException( + "Both remote name and URL are required to add a git remote." + ) + + args = ["remote", "add"] + if fetch: + args.append("-f") + args.extend([name, url]) + + if not overwrite: + return await self._run_git( + args, path, envs, user, cwd, timeout, request_timeout + ) + + add_cmd = build_git_command(args, path) + set_url_cmd = build_git_command(["remote", "set-url", name, url], path) + cmd = f"{add_cmd} || {set_url_cmd}" + if fetch: + fetch_cmd = build_git_command(["fetch", name], path) + cmd = f"({cmd}) && {fetch_cmd}" + return await self._run_shell( + cmd, + envs, + user, + cwd, + timeout, + request_timeout, + ) + + async def remote_get( + self, + path: str, + name: str, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ) -> Optional[str]: + """ + Get the URL for a git remote. + + Returns `None` when the remote does not exist. + + :param path: Repository path + :param name: Remote name (for example, "origin") + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Remote URL if present, otherwise `None` + """ + if not name: + raise InvalidArgumentException("Remote name is required.") + + cmd = f"{build_git_command(['remote', 'get-url', name], path)} || true" + result = ( + await self._run_shell( + cmd, + envs, + user, + cwd, + timeout, + request_timeout, + ) + ).stdout.strip() + return result or None + + async def status( + self, + path: str, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ) -> GitStatus: + """ + Get repository status information. + + :param path: Repository path + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Parsed git status + """ + result = await self._run_git( + ["status", "--porcelain=1", "-b"], + path, + envs, + user, + cwd, + timeout, + request_timeout, + ) + return parse_git_status(result.stdout) + + async def branches( + self, + path: str, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ) -> GitBranches: + """ + List branches in a repository. + + :param path: Repository path + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Parsed branch list + """ + result = await self._run_git( + ["branch", "--format=%(refname:short)\t%(HEAD)"], + path, + envs, + user, + cwd, + timeout, + request_timeout, + ) + return parse_git_branches(result.stdout) + + async def create_branch( + self, + path: str, + branch: str, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Create and check out a new branch. + + :param path: Repository path + :param branch: Branch name to create + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + return await self._run_git( + ["checkout", "-b", branch], + path, + envs, + user, + cwd, + timeout, + request_timeout, + ) + + async def checkout_branch( + self, + path: str, + branch: str, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Check out an existing branch. + + :param path: Repository path + :param branch: Branch name to check out + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + return await self._run_git( + ["checkout", branch], + path, + envs, + user, + cwd, + timeout, + request_timeout, + ) + + async def delete_branch( + self, + path: str, + branch: str, + force: bool = False, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Delete a branch. + + :param path: Repository path + :param branch: Branch name to delete + :param force: Force deletion with `-D` when `True` + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + args = ["branch", "-D" if force else "-d", branch] + return await self._run_git( + args, path, envs, user, cwd, timeout, request_timeout + ) + + async def add( + self, + path: str, + files: Optional[List[str]] = None, + all: bool = True, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Stage files for commit. + + :param path: Repository path + :param files: Files to add; when omitted, adds the current directory + :param all: When `True` and `files` is omitted, stage all changes + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + args = ["add"] + if not files: + args.append("-A" if all else ".") + else: + args.append("--") + args.extend(files) + return await self._run_git( + args, path, envs, user, cwd, timeout, request_timeout + ) + + async def commit( + self, + path: str, + message: str, + author_name: Optional[str] = None, + author_email: Optional[str] = None, + allow_empty: bool = False, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Create a commit in the repository. + + :param path: Repository path + :param message: Commit message + :param author_name: Commit author name + :param author_email: Commit author email + :param allow_empty: Allow empty commits when `True` + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + args = ["commit", "-m", message] + if allow_empty: + args.append("--allow-empty") + author_args: List[str] = [] + if author_name: + author_args.extend(["-c", f"user.name={author_name}"]) + if author_email: + author_args.extend(["-c", f"user.email={author_email}"]) + if author_args: + args = author_args + args + return await self._run_git( + args, path, envs, user, cwd, timeout, request_timeout + ) + + async def push( + self, + path: str, + remote: Optional[str] = None, + branch: Optional[str] = None, + set_upstream: bool = True, + username: Optional[str] = None, + password: Optional[str] = None, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Push commits to a remote. + + :param path: Repository path + :param remote: Remote name, e.g. `origin` + :param branch: Branch name to push + :param set_upstream: Set upstream tracking when `True` + :param username: Username for HTTP(S) authentication + :param password: Password or token for HTTP(S) authentication + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + if password and not username: + raise InvalidArgumentException( + "Username is required when using a password or token for git push." + ) + + if username and password: + remote_name = await self._resolve_remote_name( + path, remote, envs, user, cwd, timeout, request_timeout + ) + return await self._with_remote_credentials( + path, + remote_name, + username, + password, + envs, + user, + cwd, + timeout, + request_timeout, + operation=lambda: self._run_git( + build_push_args( + remote_name, + remote=remote, + branch=branch, + set_upstream=set_upstream, + ), + path, + envs, + user, + cwd, + timeout, + request_timeout, + ), + ) + + try: + return await self._run_git( + build_push_args( + None, + remote=remote, + branch=branch, + set_upstream=set_upstream, + ), + path, + envs, + user, + cwd, + timeout, + request_timeout, + ) + except CommandExitException as err: + if is_auth_failure(err): + raise GitAuthException( + build_auth_error_message("push", bool(username) and not password) + ) from err + if is_missing_upstream(err): + raise GitUpstreamException( + build_upstream_error_message("push") + ) from err + raise + + async def pull( + self, + path: str, + remote: Optional[str] = None, + branch: Optional[str] = None, + username: Optional[str] = None, + password: Optional[str] = None, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Pull changes from a remote. + + :param path: Repository path + :param remote: Remote name, e.g. `origin` + :param branch: Branch name to pull + :param username: Username for HTTP(S) authentication + :param password: Password or token for HTTP(S) authentication + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + if password and not username: + raise InvalidArgumentException( + "Username is required when using a password or token for git pull." + ) + + if not remote and not branch: + has_upstream = await self._has_upstream( + path, envs, user, cwd, timeout, request_timeout + ) + if not has_upstream: + raise GitUpstreamException(build_upstream_error_message("pull")) + + def build_args(remote_name: Optional[str] = None) -> List[str]: + args = ["pull"] + target_remote = remote_name or remote + if target_remote: + args.append(target_remote) + if branch: + args.append(branch) + return args + + if username and password: + remote_name = await self._resolve_remote_name( + path, remote, envs, user, cwd, timeout, request_timeout + ) + return await self._with_remote_credentials( + path, + remote_name, + username, + password, + envs, + user, + cwd, + timeout, + request_timeout, + operation=lambda: self._run_git( + build_args(remote_name), + path, + envs, + user, + cwd, + timeout, + request_timeout, + ), + ) + + try: + return await self._run_git( + build_args(), path, envs, user, cwd, timeout, request_timeout + ) + except CommandExitException as err: + if is_auth_failure(err): + raise GitAuthException( + build_auth_error_message("pull", bool(username) and not password) + ) from err + if is_missing_upstream(err): + raise GitUpstreamException( + build_upstream_error_message("pull") + ) from err + raise + + async def set_config( + self, + key: str, + value: str, + scope: str = "global", + path: Optional[str] = None, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Set a git config value. + + Use `scope="local"` together with `path` to configure a specific repository. + + :param key: Git config key (e.g. `pull.rebase`) + :param value: Git config value + :param scope: Config scope: `global`, `local`, or `system` + :param path: Repository path required when `scope` is `local` + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + if not key: + raise InvalidArgumentException("Git config key is required.") + + scope_flag, repo_path = resolve_config_scope(scope, path) + return await self._run_git( + ["config", scope_flag, key, value], + repo_path, + envs, + user, + cwd, + timeout, + request_timeout, + ) + + async def get_config( + self, + key: str, + scope: str = "global", + path: Optional[str] = None, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ) -> Optional[str]: + """ + Get a git config value. + + Returns `None` when the key is not set in the requested scope. + + :param key: Git config key (e.g. `pull.rebase`) + :param scope: Config scope: `global`, `local`, or `system` + :param path: Repository path required when `scope` is `local` + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Config value if present, otherwise `None` + """ + if not key: + raise InvalidArgumentException("Git config key is required.") + + scope_flag, repo_path = resolve_config_scope(scope, path) + cmd = ( + f"{build_git_command(['config', scope_flag, '--get', key], repo_path)} " + "|| true" + ) + result = ( + await self._run_shell( + cmd, + envs, + user, + cwd, + timeout, + request_timeout, + ) + ).stdout.strip() + return result or None + + async def dangerously_authenticate( + self, + username: str, + password: str, + host: str = "github.com", + protocol: str = "https", + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Dangerously authenticate git globally via the credential helper. + + This persists credentials in the credential store and may be accessable to agents running on the sandbox. + Prefer short-lived credentials when possible. + + :param username: Username for HTTP(S) authentication + :param password: Password or token for HTTP(S) authentication + :param host: Host to authenticate for, defaults to `github.com` + :param protocol: Protocol to authenticate for, defaults to `https` + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + if not username or not password: + raise InvalidArgumentException( + "Both username and password are required to authenticate git." + ) + + target_host = host.strip() or "github.com" + target_protocol = protocol.strip() or "https" + credential_input = "\n".join( + [ + f"protocol={target_protocol}", + f"host={target_host}", + f"username={username}", + f"password={password}", + "", + "", + ] + ) + + await self.set_config( + "credential.helper", + "store", + scope="global", + envs=envs, + user=user, + cwd=cwd, + timeout=timeout, + request_timeout=request_timeout, + ) + approve_cmd = ( + f"printf %s {shell_escape(credential_input)} | " + f"{build_git_command(['credential', 'approve'])}" + ) + return await self._run_shell( + approve_cmd, + envs, + user, + cwd, + timeout, + request_timeout, + ) + + async def configure_user( + self, + name: str, + email: str, + scope: str = "global", + path: Optional[str] = None, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Configure git user name and email. + + :param name: Git user name + :param email: Git user email + :param scope: Config scope: `global`, `local`, or `system` + :param path: Repository path required when `scope` is `local` + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + if not name or not email: + raise InvalidArgumentException("Both name and email are required.") + + await self.set_config( + "user.name", + name, + scope=scope, + path=path, + envs=envs, + user=user, + cwd=cwd, + timeout=timeout, + request_timeout=request_timeout, + ) + return await self.set_config( + "user.email", + email, + scope=scope, + path=path, + envs=envs, + user=user, + cwd=cwd, + timeout=timeout, + request_timeout=request_timeout, + ) diff --git a/packages/python-sdk/e2b/sandbox_async/main.py b/packages/python-sdk/e2b/sandbox_async/main.py index 12773db463..bc592f139e 100644 --- a/packages/python-sdk/e2b/sandbox_async/main.py +++ b/packages/python-sdk/e2b/sandbox_async/main.py @@ -20,6 +20,7 @@ from e2b.sandbox_async.commands.command import Commands from e2b.sandbox_async.commands.pty import Pty from e2b.sandbox_async.filesystem.filesystem import Filesystem +from e2b.sandbox_async.git import Git from e2b.sandbox_async.sandbox_api import SandboxApi, SandboxInfo logger = logging.getLogger(__name__) @@ -69,6 +70,13 @@ def pty(self) -> Pty: """ return self._pty + @property + def git(self) -> Git: + """ + Module for running git operations in the sandbox. + """ + return self._git + def __init__( self, **opts: Unpack[SandboxOpts], @@ -105,6 +113,7 @@ def __init__( self._transport.pool, self._envd_version, ) + self._git = Git(self._commands) async def is_running(self, request_timeout: Optional[float] = None) -> bool: """ diff --git a/packages/python-sdk/e2b/sandbox_sync/git.py b/packages/python-sdk/e2b/sandbox_sync/git.py new file mode 100644 index 0000000000..2e0653963f --- /dev/null +++ b/packages/python-sdk/e2b/sandbox_sync/git.py @@ -0,0 +1,1161 @@ +from typing import Dict, List, Optional + +from e2b.sandbox.git_utils import ( + GitBranches, + GitStatus, + build_auth_error_message, + build_git_command, + build_push_args, + build_upstream_error_message, + is_auth_failure, + is_missing_upstream, + parse_git_branches, + parse_git_status, + resolve_config_scope, + shell_escape, + strip_credentials, + derive_repo_dir_from_url, + with_credentials, +) +from e2b.exceptions import ( + GitAuthException, + GitUpstreamException, + InvalidArgumentException, +) +from e2b.sandbox.commands.command_handle import CommandExitException +from e2b.sandbox_sync.commands.command import Commands + + +DEFAULT_GIT_ENV = {"GIT_TERMINAL_PROMPT": "0"} + + +class Git: + """ + Module for running git operations in the sandbox. + """ + + def __init__(self, commands: Commands) -> None: + """ + Create a Git helper bound to the sandbox command runner. + + :param commands: Command runner used to execute git commands + """ + self._commands = commands + + def _run_git( + self, + args: List[str], + repo_path: Optional[str], + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Build and execute a git command inside the sandbox. + + :param args: Git arguments to pass to the git binary + :param repo_path: Repository path used with `git -C`, if provided + :param envs: Extra environment variables for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + cmd = build_git_command(args, repo_path) + merged_envs = {**DEFAULT_GIT_ENV, **(envs or {})} + return self._commands.run( + cmd, + envs=merged_envs, + user=user, + cwd=cwd, + timeout=timeout, + request_timeout=request_timeout, + ) + + def _run_shell( + self, + cmd: str, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Execute a raw shell command while applying default git environment variables. + + :param cmd: Shell command to execute + :param envs: Extra environment variables for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + merged_envs = {**DEFAULT_GIT_ENV, **(envs or {})} + return self._commands.run( + cmd, + envs=merged_envs, + user=user, + cwd=cwd, + timeout=timeout, + request_timeout=request_timeout, + ) + + def _has_upstream( + self, + path: str, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ) -> bool: + try: + result = self._run_git( + ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], + path, + envs, + user, + cwd, + timeout, + request_timeout, + ) + return bool(result.stdout.strip()) + except Exception: + return False + + def _get_remote_url( + self, + path: str, + remote: str, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ) -> str: + result = self._run_git( + ["remote", "get-url", remote], + path, + envs, + user, + cwd, + timeout, + request_timeout, + ) + url = result.stdout.strip() + if not url: + raise InvalidArgumentException( + f'Remote "{remote}" URL not found in repository.' + ) + return url + + def _resolve_remote_name( + self, + path: str, + remote: Optional[str], + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ) -> str: + if remote: + return remote + + result = self._run_git( + ["remote"], + path, + envs, + user, + cwd, + timeout, + request_timeout, + ) + remotes = [line.strip() for line in result.stdout.splitlines() if line.strip()] + if len(remotes) == 1: + return remotes[0] + + raise InvalidArgumentException( + "Remote is required when using username/password and the repository has multiple remotes." + ) + + def _with_remote_credentials( + self, + path: str, + remote: str, + username: str, + password: str, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + operation=None, + ): + original_url = self._get_remote_url( + path, remote, envs, user, cwd, timeout, request_timeout + ) + credential_url = with_credentials(original_url, username, password) + self._run_git( + ["remote", "set-url", remote, credential_url], + path, + envs, + user, + cwd, + timeout, + request_timeout, + ) + + result = None + operation_error: Exception | None = None + try: + if operation is None: + raise InvalidArgumentException("Operation is required.") + result = operation() + except Exception as err: + operation_error = err + + restore_error: Exception | None = None + try: + self._run_git( + ["remote", "set-url", remote, original_url], + path, + envs, + user, + cwd, + timeout, + request_timeout, + ) + except Exception as err: + restore_error = err + + if operation_error: + raise operation_error + if restore_error: + raise restore_error + + return result + + def clone( + self, + url: str, + path: Optional[str] = None, + branch: Optional[str] = None, + depth: Optional[int] = None, + username: Optional[str] = None, + password: Optional[str] = None, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + dangerously_store_credentials: bool = False, + ): + """ + Clone a git repository into the sandbox. + + :param url: Git repository URL + :param path: Destination path for the clone + :param branch: Branch to check out + :param depth: If set, perform a shallow clone with this depth + :param username: Username for HTTP(S) authentication + :param password: Password or token for HTTP(S) authentication + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :param dangerously_store_credentials: Store credentials in the cloned repository when True + :return: Command result from the command runner + """ + if password and not username: + raise InvalidArgumentException( + "Username is required when using a password or token for git clone." + ) + + def attempt_clone(auth_username: Optional[str], auth_password: Optional[str]): + clone_url = ( + with_credentials(url, auth_username, auth_password) + if auth_username and auth_password + else url + ) + sanitized_url = strip_credentials(clone_url) + should_strip = ( + not dangerously_store_credentials and sanitized_url != clone_url + ) + repo_path = ( + path if not should_strip else path or derive_repo_dir_from_url(url) + ) + if should_strip and not repo_path: + raise InvalidArgumentException( + "A destination path is required when using credentials without storing them." + ) + args = ["clone", clone_url] + if branch: + args.extend(["--branch", branch, "--single-branch"]) + if depth: + args.extend(["--depth", str(depth)]) + if path: + args.append(path) + result = self._run_git( + args, None, envs, user, cwd, timeout, request_timeout + ) + if should_strip and repo_path: + self._run_git( + ["remote", "set-url", "origin", sanitized_url], + repo_path, + envs, + user, + cwd, + timeout, + request_timeout, + ) + return result + + try: + return attempt_clone(username, password) + except CommandExitException as err: + if is_auth_failure(err): + raise GitAuthException( + build_auth_error_message("clone", bool(username) and not password) + ) from err + raise + + def init( + self, + path: str, + bare: bool = False, + initial_branch: Optional[str] = None, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Initialize a new git repository. + + :param path: Destination path for the repository + :param bare: Create a bare repository when True + :param initial_branch: Initial branch name (for example, "main") + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + args = ["init"] + if initial_branch: + args.extend(["--initial-branch", initial_branch]) + if bare: + args.append("--bare") + args.append(path) + return self._run_git(args, None, envs, user, cwd, timeout, request_timeout) + + def remote_add( + self, + path: str, + name: str, + url: str, + fetch: bool = False, + overwrite: bool = False, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Add (or update) a remote for a repository. + + :param path: Repository path + :param name: Remote name (for example, "origin") + :param url: Remote URL + :param fetch: Fetch the remote after adding it when True + :param overwrite: Overwrite the remote URL if it already exists when True + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + if not name or not url: + raise InvalidArgumentException( + "Both remote name and URL are required to add a git remote." + ) + + args = ["remote", "add"] + if fetch: + args.append("-f") + args.extend([name, url]) + + if not overwrite: + return self._run_git(args, path, envs, user, cwd, timeout, request_timeout) + + add_cmd = build_git_command(args, path) + set_url_cmd = build_git_command(["remote", "set-url", name, url], path) + cmd = f"{add_cmd} || {set_url_cmd}" + if fetch: + fetch_cmd = build_git_command(["fetch", name], path) + cmd = f"({cmd}) && {fetch_cmd}" + return self._run_shell( + cmd, + envs, + user, + cwd, + timeout, + request_timeout, + ) + + def remote_get( + self, + path: str, + name: str, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ) -> Optional[str]: + """ + Get the URL for a git remote. + + Returns `None` when the remote does not exist. + + :param path: Repository path + :param name: Remote name (for example, "origin") + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Remote URL if present, otherwise `None` + """ + if not name: + raise InvalidArgumentException("Remote name is required.") + + cmd = f"{build_git_command(['remote', 'get-url', name], path)} || true" + result = self._run_shell( + cmd, + envs, + user, + cwd, + timeout, + request_timeout, + ).stdout.strip() + return result or None + + def status( + self, + path: str, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ) -> GitStatus: + """ + Get repository status information. + + :param path: Repository path + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Parsed git status + """ + result = self._run_git( + ["status", "--porcelain=1", "-b"], + path, + envs, + user, + cwd, + timeout, + request_timeout, + ) + return parse_git_status(result.stdout) + + def branches( + self, + path: str, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ) -> GitBranches: + """ + List branches in a repository. + + :param path: Repository path + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Parsed branch list + """ + result = self._run_git( + ["branch", "--format=%(refname:short)\t%(HEAD)"], + path, + envs, + user, + cwd, + timeout, + request_timeout, + ) + return parse_git_branches(result.stdout) + + def create_branch( + self, + path: str, + branch: str, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Create and check out a new branch. + + :param path: Repository path + :param branch: Branch name to create + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + return self._run_git( + ["checkout", "-b", branch], + path, + envs, + user, + cwd, + timeout, + request_timeout, + ) + + def checkout_branch( + self, + path: str, + branch: str, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Check out an existing branch. + + :param path: Repository path + :param branch: Branch name to check out + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + return self._run_git( + ["checkout", branch], + path, + envs, + user, + cwd, + timeout, + request_timeout, + ) + + def delete_branch( + self, + path: str, + branch: str, + force: bool = False, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Delete a branch. + + :param path: Repository path + :param branch: Branch name to delete + :param force: Force deletion with `-D` when `True` + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + args = ["branch", "-D" if force else "-d", branch] + return self._run_git(args, path, envs, user, cwd, timeout, request_timeout) + + def add( + self, + path: str, + files: Optional[List[str]] = None, + all: bool = True, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Stage files for commit. + + :param path: Repository path + :param files: Files to add; when omitted, adds the current directory + :param all: When `True` and `files` is omitted, stage all changes + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + args = ["add"] + if not files: + args.append("-A" if all else ".") + else: + args.append("--") + args.extend(files) + return self._run_git(args, path, envs, user, cwd, timeout, request_timeout) + + def commit( + self, + path: str, + message: str, + author_name: Optional[str] = None, + author_email: Optional[str] = None, + allow_empty: bool = False, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Create a commit in the repository. + + :param path: Repository path + :param message: Commit message + :param author_name: Commit author name + :param author_email: Commit author email + :param allow_empty: Allow empty commits when `True` + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + args = ["commit", "-m", message] + if allow_empty: + args.append("--allow-empty") + author_args: List[str] = [] + if author_name: + author_args.extend(["-c", f"user.name={author_name}"]) + if author_email: + author_args.extend(["-c", f"user.email={author_email}"]) + if author_args: + args = author_args + args + return self._run_git(args, path, envs, user, cwd, timeout, request_timeout) + + def reset( + self, + path: str, + mode: Optional[str] = None, + target: Optional[str] = None, + paths: Optional[List[str]] = None, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Reset the current HEAD to a specified state. + + :param path: Repository path + :param mode: Reset mode (soft, mixed, hard, merge, keep) + :param target: Commit, branch, or ref to reset to (defaults to HEAD) + :param paths: Paths to reset + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + allowed_modes = {"soft", "mixed", "hard", "merge", "keep"} + if mode and mode not in allowed_modes: + raise InvalidArgumentException( + f"Reset mode must be one of {', '.join(sorted(allowed_modes))}." + ) + + args = ["reset"] + if mode: + args.append(f"--{mode}") + if target: + args.append(target) + if paths: + args.append("--") + args.extend(paths) + return self._run_git(args, path, envs, user, cwd, timeout, request_timeout) + + def restore( + self, + path: str, + paths: List[str], + staged: Optional[bool] = None, + worktree: Optional[bool] = None, + source: Optional[str] = None, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Restore working tree files or unstage changes. + + :param path: Repository path + :param paths: Paths to restore (use ["."] for all) + :param staged: When True, restore the index (unstage) + :param worktree: When True, restore working tree files + :param source: Restore from the given source (commit, branch, or ref) + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + if not paths: + raise InvalidArgumentException("At least one path is required.") + + resolved_staged = staged + resolved_worktree = worktree + if staged is None and worktree is None: + resolved_worktree = True + elif staged is True and worktree is None: + resolved_worktree = False + elif staged is None and worktree is not None: + resolved_staged = False + + if resolved_staged is False and resolved_worktree is False: + raise InvalidArgumentException( + "At least one of staged or worktree must be true." + ) + + args = ["restore"] + if resolved_worktree: + args.append("--worktree") + if resolved_staged: + args.append("--staged") + if source: + args.extend(["--source", source]) + args.append("--") + args.extend(paths) + return self._run_git(args, path, envs, user, cwd, timeout, request_timeout) + + def push( + self, + path: str, + remote: Optional[str] = None, + branch: Optional[str] = None, + set_upstream: bool = True, + username: Optional[str] = None, + password: Optional[str] = None, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Push commits to a remote. + + :param path: Repository path + :param remote: Remote name, e.g. `origin` + :param branch: Branch name to push + :param set_upstream: Set upstream tracking when `True` + :param username: Username for HTTP(S) authentication + :param password: Password or token for HTTP(S) authentication + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + if password and not username: + raise InvalidArgumentException( + "Username is required when using a password or token for git push." + ) + + if username and password: + remote_name = self._resolve_remote_name( + path, remote, envs, user, cwd, timeout, request_timeout + ) + return self._with_remote_credentials( + path, + remote_name, + username, + password, + envs, + user, + cwd, + timeout, + request_timeout, + operation=lambda: self._run_git( + build_push_args( + remote_name, + remote=remote, + branch=branch, + set_upstream=set_upstream, + ), + path, + envs, + user, + cwd, + timeout, + request_timeout, + ), + ) + + try: + return self._run_git( + build_push_args( + None, + remote=remote, + branch=branch, + set_upstream=set_upstream, + ), + path, + envs, + user, + cwd, + timeout, + request_timeout, + ) + except CommandExitException as err: + if is_auth_failure(err): + raise GitAuthException( + build_auth_error_message("push", bool(username) and not password) + ) from err + if is_missing_upstream(err): + raise GitUpstreamException( + build_upstream_error_message("push") + ) from err + raise + + def pull( + self, + path: str, + remote: Optional[str] = None, + branch: Optional[str] = None, + username: Optional[str] = None, + password: Optional[str] = None, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Pull changes from a remote. + + :param path: Repository path + :param remote: Remote name, e.g. `origin` + :param branch: Branch name to pull + :param username: Username for HTTP(S) authentication + :param password: Password or token for HTTP(S) authentication + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + if password and not username: + raise InvalidArgumentException( + "Username is required when using a password or token for git pull." + ) + + if not remote and not branch: + if not self._has_upstream(path, envs, user, cwd, timeout, request_timeout): + raise GitUpstreamException(build_upstream_error_message("pull")) + + def build_args(remote_name: Optional[str] = None) -> List[str]: + args = ["pull"] + target_remote = remote_name or remote + if target_remote: + args.append(target_remote) + if branch: + args.append(branch) + return args + + if username and password: + remote_name = self._resolve_remote_name( + path, remote, envs, user, cwd, timeout, request_timeout + ) + return self._with_remote_credentials( + path, + remote_name, + username, + password, + envs, + user, + cwd, + timeout, + request_timeout, + operation=lambda: self._run_git( + build_args(remote_name), + path, + envs, + user, + cwd, + timeout, + request_timeout, + ), + ) + + try: + return self._run_git( + build_args(), path, envs, user, cwd, timeout, request_timeout + ) + except CommandExitException as err: + if is_auth_failure(err): + raise GitAuthException( + build_auth_error_message("pull", bool(username) and not password) + ) from err + if is_missing_upstream(err): + raise GitUpstreamException( + build_upstream_error_message("pull") + ) from err + raise + + def set_config( + self, + key: str, + value: str, + scope: str = "global", + path: Optional[str] = None, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Set a git config value. + + Use `scope="local"` together with `path` to configure a specific repository. + + :param key: Git config key (e.g. `pull.rebase`) + :param value: Git config value + :param scope: Config scope: `global`, `local`, or `system` + :param path: Repository path required when `scope` is `local` + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + if not key: + raise InvalidArgumentException("Git config key is required.") + + scope_flag, repo_path = resolve_config_scope(scope, path) + return self._run_git( + ["config", scope_flag, key, value], + repo_path, + envs, + user, + cwd, + timeout, + request_timeout, + ) + + def get_config( + self, + key: str, + scope: str = "global", + path: Optional[str] = None, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ) -> Optional[str]: + """ + Get a git config value. + + Returns `None` when the key is not set in the requested scope. + + :param key: Git config key (e.g. `pull.rebase`) + :param scope: Config scope: `global`, `local`, or `system` + :param path: Repository path required when `scope` is `local` + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Config value if present, otherwise `None` + """ + if not key: + raise InvalidArgumentException("Git config key is required.") + + scope_flag, repo_path = resolve_config_scope(scope, path) + cmd = ( + f"{build_git_command(['config', scope_flag, '--get', key], repo_path)} " + "|| true" + ) + result = self._run_shell( + cmd, + envs, + user, + cwd, + timeout, + request_timeout, + ).stdout.strip() + return result or None + + def dangerously_authenticate( + self, + username: str, + password: str, + host: str = "github.com", + protocol: str = "https", + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Dangerously authenticate git globally via the credential helper. + + This persists credentials in the credential store and may be accessable to agents running on the sandbox. + Prefer short-lived credentials when possible. + + :param username: Username for HTTP(S) authentication + :param password: Password or token for HTTP(S) authentication + :param host: Host to authenticate for, defaults to `github.com` + :param protocol: Protocol to authenticate for, defaults to `https` + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + if not username or not password: + raise InvalidArgumentException( + "Both username and password are required to authenticate git." + ) + + target_host = host.strip() or "github.com" + target_protocol = protocol.strip() or "https" + credential_input = "\n".join( + [ + f"protocol={target_protocol}", + f"host={target_host}", + f"username={username}", + f"password={password}", + "", + "", + ] + ) + + self.set_config( + "credential.helper", + "store", + scope="global", + envs=envs, + user=user, + cwd=cwd, + timeout=timeout, + request_timeout=request_timeout, + ) + approve_cmd = ( + f"printf %s {shell_escape(credential_input)} | " + f"{build_git_command(['credential', 'approve'])}" + ) + return self._run_shell( + approve_cmd, + envs, + user, + cwd, + timeout, + request_timeout, + ) + + def configure_user( + self, + name: str, + email: str, + scope: str = "global", + path: Optional[str] = None, + envs: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cwd: Optional[str] = None, + timeout: Optional[float] = None, + request_timeout: Optional[float] = None, + ): + """ + Configure git user name and email. + + :param name: Git user name + :param email: Git user email + :param scope: Config scope: `global`, `local`, or `system` + :param path: Repository path required when `scope` is `local` + :param envs: Environment variables used for the command + :param user: User to run the command as + :param cwd: Working directory to run the command + :param timeout: Timeout for the command connection in **seconds** + :param request_timeout: Timeout for the request in **seconds** + :return: Command result from the command runner + """ + if not name or not email: + raise InvalidArgumentException("Both name and email are required.") + + self.set_config( + "user.name", + name, + scope=scope, + path=path, + envs=envs, + user=user, + cwd=cwd, + timeout=timeout, + request_timeout=request_timeout, + ) + return self.set_config( + "user.email", + email, + scope=scope, + path=path, + envs=envs, + user=user, + cwd=cwd, + timeout=timeout, + request_timeout=request_timeout, + ) diff --git a/packages/python-sdk/e2b/sandbox_sync/main.py b/packages/python-sdk/e2b/sandbox_sync/main.py index 032353e84e..366def2234 100644 --- a/packages/python-sdk/e2b/sandbox_sync/main.py +++ b/packages/python-sdk/e2b/sandbox_sync/main.py @@ -20,6 +20,7 @@ from e2b.sandbox_sync.commands.command import Commands from e2b.sandbox_sync.commands.pty import Pty from e2b.sandbox_sync.filesystem.filesystem import Filesystem +from e2b.sandbox_sync.git import Git from e2b.sandbox_sync.sandbox_api import SandboxApi, SandboxInfo logger = logging.getLogger(__name__) @@ -69,6 +70,13 @@ def pty(self) -> Pty: """ return self._pty + @property + def git(self) -> Git: + """ + Module for running git operations in the sandbox. + """ + return self._git + def __init__(self, **opts: Unpack[SandboxOpts]): """ :deprecated: This constructor is deprecated @@ -103,6 +111,7 @@ def __init__(self, **opts: Unpack[SandboxOpts]): self._transport.pool, self._envd_version, ) + self._git = Git(self._commands) def is_running(self, request_timeout: Optional[float] = None) -> bool: """ diff --git a/packages/python-sdk/pytest.ini b/packages/python-sdk/pytest.ini index ec8e70d5a5..b3593565b0 100644 --- a/packages/python-sdk/pytest.ini +++ b/packages/python-sdk/pytest.ini @@ -6,3 +6,6 @@ markers = asyncio_mode=auto addopts = "--import-mode=importlib" timeout = 300 +filterwarnings = + ignore:'asyncio\.iscoroutinefunction' is deprecated.*:DeprecationWarning:pytest_asyncio\.plugin + ignore:'asyncio\.get_event_loop_policy' is deprecated.*:DeprecationWarning:pytest_asyncio\.plugin diff --git a/packages/python-sdk/tests/shared/git/conftest.py b/packages/python-sdk/tests/shared/git/conftest.py new file mode 100644 index 0000000000..60a5e0bdf4 --- /dev/null +++ b/packages/python-sdk/tests/shared/git/conftest.py @@ -0,0 +1,74 @@ +import random +from uuid import uuid4 + +import pytest + +BASE_DIR = "/tmp/test-git" + + +@pytest.fixture +def git_sandbox(sandbox_factory): + return sandbox_factory(timeout=10) + + +@pytest.fixture +def git_author(): + return "Sandbox Bot", "sandbox@example.com" + + +@pytest.fixture +def git_credentials(): + return "git", "token", "example.com", "https" + + +@pytest.fixture +def git_base_dir(git_sandbox): + base_dir = f"{BASE_DIR}/{uuid4().hex}" + git_sandbox.commands.run(f'rm -rf "{base_dir}" && mkdir -p "{base_dir}"') + yield base_dir + git_sandbox.commands.run(f'rm -rf "{base_dir}"') + + +@pytest.fixture +def git_repo(git_sandbox, git_base_dir, git_author): + repo_path = f"{git_base_dir}/repo" + git_sandbox.git.init(repo_path, initial_branch="main") + author_name, author_email = git_author + git_sandbox.git.configure_user(author_name, author_email) + return repo_path + + +@pytest.fixture +def git_repo_with_commit(git_sandbox, git_repo, git_author): + author_name, author_email = git_author + git_sandbox.files.write(f"{git_repo}/README.md", "hello\n") + git_sandbox.git.add(git_repo, all=True) + git_sandbox.git.commit( + git_repo, + message="Initial commit", + author_name=author_name, + author_email=author_email, + ) + return git_repo + + +@pytest.fixture +def git_daemon(git_sandbox, git_base_dir): + remote_path = f"{git_base_dir}/remote.git" + git_sandbox.commands.run(f'git init --bare --initial-branch=main "{remote_path}"') + port = 9418 + random.randint(0, 1000) + cmd = ( + f'git daemon --reuseaddr --base-path="{git_base_dir}" --export-all ' + f"--enable=receive-pack --informative-errors --listen=127.0.0.1 --port={port}" + ) + handle = git_sandbox.commands.run(cmd, background=True) + git_sandbox.commands.run("sleep 1") + try: + yield { + "remote_path": remote_path, + "remote_url": f"git://127.0.0.1:{port}/remote.git", + "port": port, + "base_dir": git_base_dir, + } + finally: + handle.kill() diff --git a/packages/python-sdk/tests/shared/git/test_add.py b/packages/python-sdk/tests/shared/git/test_add.py new file mode 100644 index 0000000000..5e549a3aed --- /dev/null +++ b/packages/python-sdk/tests/shared/git/test_add.py @@ -0,0 +1,16 @@ +import pytest + + +@pytest.mark.skip_debug() +def test_add_stages_files(git_sandbox, git_repo): + git_sandbox.files.write(f"{git_repo}/README.md", "hello\n") + + git_sandbox.git.add(git_repo, all=True) + + status = git_sandbox.git.status(git_repo) + entry = next( + (item for item in status.file_status if item.name == "README.md"), None + ) + assert entry is not None + assert entry.status == "added" + assert entry.staged is True diff --git a/packages/python-sdk/tests/shared/git/test_branches.py b/packages/python-sdk/tests/shared/git/test_branches.py new file mode 100644 index 0000000000..c2385f1fc2 --- /dev/null +++ b/packages/python-sdk/tests/shared/git/test_branches.py @@ -0,0 +1,52 @@ +import pytest + + +@pytest.mark.skip_debug() +def test_branches_lists_current_and_feature(git_sandbox, git_repo_with_commit): + repo_path = git_repo_with_commit + + git_sandbox.commands.run(f'git -C "{repo_path}" branch feature') + + branches = git_sandbox.git.branches(repo_path) + assert branches.current_branch == "main" + assert "main" in branches.branches + assert "feature" in branches.branches + + +@pytest.mark.skip_debug() +def test_checkout_branch_switches_branch(git_sandbox, git_repo_with_commit): + repo_path = git_repo_with_commit + + git_sandbox.commands.run(f'git -C "{repo_path}" branch feature') + git_sandbox.git.checkout_branch(repo_path, "feature") + + head = git_sandbox.commands.run( + f'git -C "{repo_path}" rev-parse --abbrev-ref HEAD' + ).stdout.strip() + assert head == "feature" + + +@pytest.mark.skip_debug() +def test_create_branch_creates_branch(git_sandbox, git_repo_with_commit): + repo_path = git_repo_with_commit + + git_sandbox.git.create_branch(repo_path, "feature") + + branches = git_sandbox.git.branches(repo_path) + assert "feature" in branches.branches + assert branches.current_branch == "feature" + + +@pytest.mark.skip_debug() +def test_delete_branch_removes_branch(git_sandbox, git_repo_with_commit): + repo_path = git_repo_with_commit + + git_sandbox.commands.run(f'git -C "{repo_path}" branch feature') + git_sandbox.git.delete_branch(repo_path, "feature") + + branch = git_sandbox.commands.run( + f'git -C "{repo_path}" branch --list feature' + ).stdout.strip() + branches = git_sandbox.git.branches(repo_path) + assert branch == "" + assert "feature" not in branches.branches diff --git a/packages/python-sdk/tests/shared/git/test_clone.py b/packages/python-sdk/tests/shared/git/test_clone.py new file mode 100644 index 0000000000..9281319071 --- /dev/null +++ b/packages/python-sdk/tests/shared/git/test_clone.py @@ -0,0 +1,22 @@ +import pytest + + +@pytest.mark.skip_debug() +def test_clone_fetches_repo( + git_sandbox, git_repo_with_commit, git_daemon, git_base_dir +): + repo_path = git_repo_with_commit + remote_url = git_daemon["remote_url"] + clone_path = f"{git_base_dir}/clone" + + git_sandbox.git.remote_add(repo_path, "origin", remote_url) + git_sandbox.git.push( + repo_path, + remote="origin", + branch="main", + set_upstream=True, + ) + + git_sandbox.git.clone(remote_url, clone_path) + contents = git_sandbox.files.read(f"{clone_path}/README.md") + assert "hello" in contents diff --git a/packages/python-sdk/tests/shared/git/test_commit.py b/packages/python-sdk/tests/shared/git/test_commit.py new file mode 100644 index 0000000000..5796e37c90 --- /dev/null +++ b/packages/python-sdk/tests/shared/git/test_commit.py @@ -0,0 +1,44 @@ +import pytest + + +@pytest.mark.skip_debug() +def test_commit_creates_commit(git_sandbox, git_repo, git_author): + author_name, author_email = git_author + + git_sandbox.files.write(f"{git_repo}/README.md", "hello\n") + git_sandbox.git.add(git_repo, all=True) + git_sandbox.git.commit( + git_repo, + message="Initial commit", + author_name=author_name, + author_email=author_email, + ) + + message = git_sandbox.commands.run( + f'git -C "{git_repo}" log -1 --pretty=%B' + ).stdout.strip() + assert message == "Initial commit" + + +@pytest.mark.skip_debug() +def test_commit_uses_config_for_missing_author(git_sandbox, git_repo, git_author): + _, expected_email = git_author + + git_sandbox.files.write(f"{git_repo}/README.md", "hello\n") + git_sandbox.git.add(git_repo, all=True) + override_name = "Override Bot" + git_sandbox.git.commit( + git_repo, + message="Partial author commit", + author_name=override_name, + ) + + author_name = git_sandbox.commands.run( + f'git -C "{git_repo}" log -1 --pretty=%an' + ).stdout.strip() + logged_email = git_sandbox.commands.run( + f'git -C "{git_repo}" log -1 --pretty=%ae' + ).stdout.strip() + + assert author_name == override_name + assert logged_email == expected_email diff --git a/packages/python-sdk/tests/shared/git/test_config.py b/packages/python-sdk/tests/shared/git/test_config.py new file mode 100644 index 0000000000..af52bf77f2 --- /dev/null +++ b/packages/python-sdk/tests/shared/git/test_config.py @@ -0,0 +1,52 @@ +import pytest + + +@pytest.mark.skip_debug() +def test_get_config_reads_local_config(git_sandbox, git_repo): + git_sandbox.commands.run(f'git -C "{git_repo}" config --local pull.rebase true') + + value = git_sandbox.git.get_config("pull.rebase", scope="local", path=git_repo) + command_value = git_sandbox.commands.run( + f'git -C "{git_repo}" config --local --get pull.rebase' + ).stdout.strip() + assert value == "true" + assert command_value == "true" + + +@pytest.mark.skip_debug() +def test_set_config_updates_local_config(git_sandbox, git_repo): + git_sandbox.git.set_config( + "pull.rebase", + "true", + scope="local", + path=git_repo, + ) + + value = git_sandbox.commands.run( + f'git -C "{git_repo}" config --local --get pull.rebase' + ).stdout.strip() + configured_value = git_sandbox.git.get_config( + "pull.rebase", scope="local", path=git_repo + ) + assert value == "true" + assert configured_value == "true" + + +@pytest.mark.skip_debug() +def test_configure_user_sets_global_config(git_sandbox, git_author): + author_name, author_email = git_author + + git_sandbox.git.configure_user(author_name, author_email) + + name = git_sandbox.commands.run( + "git config --global --get user.name" + ).stdout.strip() + email = git_sandbox.commands.run( + "git config --global --get user.email" + ).stdout.strip() + configured_name = git_sandbox.git.get_config("user.name", scope="global") + configured_email = git_sandbox.git.get_config("user.email", scope="global") + assert name == author_name + assert email == author_email + assert configured_name == author_name + assert configured_email == author_email diff --git a/packages/python-sdk/tests/shared/git/test_dangerously_authenticate.py b/packages/python-sdk/tests/shared/git/test_dangerously_authenticate.py new file mode 100644 index 0000000000..67b44b4dfb --- /dev/null +++ b/packages/python-sdk/tests/shared/git/test_dangerously_authenticate.py @@ -0,0 +1,20 @@ +import pytest + + +@pytest.mark.skip_debug() +def test_dangerously_authenticate_sets_helper(git_sandbox, git_credentials): + username, password, host, protocol = git_credentials + + git_sandbox.git.dangerously_authenticate( + username, + password, + host=host, + protocol=protocol, + ) + + helper = git_sandbox.commands.run( + "git config --global --get credential.helper" + ).stdout.strip() + configured_helper = git_sandbox.git.get_config("credential.helper", scope="global") + assert helper == "store" + assert configured_helper == "store" diff --git a/packages/python-sdk/tests/shared/git/test_init.py b/packages/python-sdk/tests/shared/git/test_init.py new file mode 100644 index 0000000000..19f5089b9b --- /dev/null +++ b/packages/python-sdk/tests/shared/git/test_init.py @@ -0,0 +1,14 @@ +import pytest + + +@pytest.mark.skip_debug() +def test_init_creates_repo(git_sandbox, git_base_dir): + repo_path = f"{git_base_dir}/repo" + + git_sandbox.git.init(repo_path, initial_branch="main") + + assert git_sandbox.files.exists(f"{repo_path}/.git") + head = git_sandbox.commands.run( + f'git -C "{repo_path}" symbolic-ref --short HEAD' + ).stdout.strip() + assert head == "main" diff --git a/packages/python-sdk/tests/shared/git/test_remote.py b/packages/python-sdk/tests/shared/git/test_remote.py new file mode 100644 index 0000000000..b66f104dd3 --- /dev/null +++ b/packages/python-sdk/tests/shared/git/test_remote.py @@ -0,0 +1,44 @@ +import pytest + + +@pytest.mark.skip_debug() +def test_remote_get_returns_none_for_missing_remote(git_sandbox, git_repo): + repo_path = git_repo + missing_url = git_sandbox.git.remote_get(repo_path, "origin") + assert missing_url is None + + +@pytest.mark.skip_debug() +def test_remote_add_adds_remote(git_sandbox, git_repo, git_daemon): + repo_path = git_repo + remote_url = git_daemon["remote_url"] + + git_sandbox.git.remote_add(repo_path, "origin", remote_url) + current_url = git_sandbox.git.remote_get(repo_path, "origin") + assert current_url == remote_url + + +@pytest.mark.skip_debug() +def test_remote_add_overwrites_existing_remote(git_sandbox, git_repo, git_daemon): + repo_path = git_repo + remote_url = git_daemon["remote_url"] + + git_sandbox.git.remote_add(repo_path, "origin", remote_url) + current_url = git_sandbox.commands.run( + f'git -C "{repo_path}" remote get-url origin' + ).stdout.strip() + current_remote = git_sandbox.git.remote_get(repo_path, "origin") + assert current_url == remote_url + assert current_remote == remote_url + + second_path = f"{git_daemon['base_dir']}/remote-2.git" + git_sandbox.commands.run(f'git init --bare --initial-branch=main "{second_path}"') + second_url = f"git://127.0.0.1:{git_daemon['port']}/remote-2.git" + git_sandbox.git.remote_add(repo_path, "origin", second_url, overwrite=True) + + updated_url = git_sandbox.commands.run( + f'git -C "{repo_path}" remote get-url origin' + ).stdout.strip() + updated_remote = git_sandbox.git.remote_get(repo_path, "origin") + assert updated_url == second_url + assert updated_remote == second_url diff --git a/packages/python-sdk/tests/shared/git/test_reset.py b/packages/python-sdk/tests/shared/git/test_reset.py new file mode 100644 index 0000000000..b216aeab0f --- /dev/null +++ b/packages/python-sdk/tests/shared/git/test_reset.py @@ -0,0 +1,17 @@ +import pytest + + +@pytest.mark.skip_debug() +def test_reset_hard_discards_changes(git_sandbox, git_repo_with_commit): + git_sandbox.files.write(f"{git_repo_with_commit}/README.md", "changed\n") + + status = git_sandbox.git.status(git_repo_with_commit) + assert status.is_clean is False + + git_sandbox.git.reset(git_repo_with_commit, mode="hard", target="HEAD") + + status_after = git_sandbox.git.status(git_repo_with_commit) + assert status_after.is_clean is True + + contents = git_sandbox.files.read(f"{git_repo_with_commit}/README.md") + assert contents == "hello\n" diff --git a/packages/python-sdk/tests/shared/git/test_restore.py b/packages/python-sdk/tests/shared/git/test_restore.py new file mode 100644 index 0000000000..53dc7f2264 --- /dev/null +++ b/packages/python-sdk/tests/shared/git/test_restore.py @@ -0,0 +1,37 @@ +import pytest + + +@pytest.mark.skip_debug() +def test_restore_unstages_changes(git_sandbox, git_repo_with_commit): + git_sandbox.files.write(f"{git_repo_with_commit}/README.md", "changed\n") + git_sandbox.git.add(git_repo_with_commit, files=["README.md"]) + + status = git_sandbox.git.status(git_repo_with_commit) + assert status.has_staged is True + + git_sandbox.git.restore( + git_repo_with_commit, + paths=["README.md"], + staged=True, + worktree=False, + ) + + status_after = git_sandbox.git.status(git_repo_with_commit) + assert status_after.has_staged is False + assert status_after.has_changes is True + + +@pytest.mark.skip_debug() +def test_restore_worktree_discards_changes(git_sandbox, git_repo_with_commit): + git_sandbox.files.write(f"{git_repo_with_commit}/README.md", "changed\n") + + status = git_sandbox.git.status(git_repo_with_commit) + assert status.is_clean is False + + git_sandbox.git.restore(git_repo_with_commit, paths=["README.md"]) + + status_after = git_sandbox.git.status(git_repo_with_commit) + assert status_after.is_clean is True + + contents = git_sandbox.files.read(f"{git_repo_with_commit}/README.md") + assert contents == "hello\n" diff --git a/packages/python-sdk/tests/shared/git/test_status.py b/packages/python-sdk/tests/shared/git/test_status.py new file mode 100644 index 0000000000..5ac450ddfb --- /dev/null +++ b/packages/python-sdk/tests/shared/git/test_status.py @@ -0,0 +1,88 @@ +import pytest + + +@pytest.mark.skip_debug() +def test_status_reports_untracked_file(git_sandbox, git_repo): + git_sandbox.files.write(f"{git_repo}/README.md", "hello\n") + + status = git_sandbox.git.status(git_repo) + entry = next( + (item for item in status.file_status if item.name == "README.md"), None + ) + + assert entry is not None + assert entry.status == "untracked" + assert status.is_clean is False + assert status.has_changes is True + assert status.has_untracked is True + assert status.has_staged is False + assert status.has_conflicts is False + assert status.total_count == 1 + assert status.staged_count == 0 + assert status.unstaged_count == 1 + assert status.untracked_count == 1 + assert status.conflict_count == 0 + + +@pytest.mark.skip_debug() +def test_status_reports_added_modified_deleted_renamed( + git_sandbox, git_repo, git_author +): + author_name, author_email = git_author + + git_sandbox.files.write(f"{git_repo}/README.md", "hello\n") + git_sandbox.files.write(f"{git_repo}/DELETE.md", "delete me\n") + git_sandbox.files.write(f"{git_repo}/RENAME.md", "rename me\n") + git_sandbox.git.add(git_repo, all=True) + git_sandbox.git.commit( + git_repo, + message="Initial commit", + author_name=author_name, + author_email=author_email, + ) + + git_sandbox.files.write(f"{git_repo}/README.md", "hello again\n") + git_sandbox.files.write(f"{git_repo}/NEW.md", "new file\n") + git_sandbox.git.add(git_repo, files=["NEW.md"]) + git_sandbox.commands.run(f'git -C "{git_repo}" rm DELETE.md') + git_sandbox.commands.run(f'git -C "{git_repo}" mv RENAME.md RENAMED.md') + + status = git_sandbox.git.status(git_repo) + modified = next( + (item for item in status.file_status if item.name == "README.md"), None + ) + added = next((item for item in status.file_status if item.name == "NEW.md"), None) + deleted = next( + (item for item in status.file_status if item.name == "DELETE.md"), None + ) + renamed = next( + (item for item in status.file_status if item.name == "RENAMED.md"), + None, + ) + + assert modified is not None + assert modified.status == "modified" + assert modified.staged is False + + assert added is not None + assert added.status == "added" + assert added.staged is True + + assert deleted is not None + assert deleted.status == "deleted" + assert deleted.staged is True + + assert renamed is not None + assert renamed.status == "renamed" + assert renamed.staged is True + assert renamed.renamed_from == "RENAME.md" + + assert status.has_changes is True + assert status.has_staged is True + assert status.has_untracked is False + assert status.has_conflicts is False + assert status.total_count == 4 + assert status.staged_count == 3 + assert status.unstaged_count == 1 + assert status.untracked_count == 0 + assert status.conflict_count == 0 diff --git a/packages/python-sdk/tests/shared/git/test_sync.py b/packages/python-sdk/tests/shared/git/test_sync.py new file mode 100644 index 0000000000..2cd591f31d --- /dev/null +++ b/packages/python-sdk/tests/shared/git/test_sync.py @@ -0,0 +1,81 @@ +import pytest + +from e2b.exceptions import GitUpstreamException + + +@pytest.mark.skip_debug() +def test_push_updates_remote(git_sandbox, git_repo_with_commit, git_daemon): + repo_path = git_repo_with_commit + remote_url = git_daemon["remote_url"] + + git_sandbox.git.remote_add(repo_path, "origin", remote_url) + git_sandbox.git.push( + repo_path, + remote="origin", + branch="main", + set_upstream=True, + ) + + message = git_sandbox.commands.run( + f'git --git-dir="{git_daemon["remote_path"]}" log -1 --pretty=%B' + ).stdout.strip() + assert message == "Initial commit" + + +@pytest.mark.skip_debug() +def test_push_without_upstream_warns(git_sandbox, git_repo_with_commit, git_daemon): + repo_path = git_repo_with_commit + remote_url = git_daemon["remote_url"] + + git_sandbox.git.remote_add(repo_path, "origin", remote_url) + + with pytest.raises(GitUpstreamException) as exc: + git_sandbox.git.push(repo_path, set_upstream=False) + + assert "no upstream branch is configured" in str(exc.value).lower() + + +@pytest.mark.skip_debug() +def test_pull_updates_clone( + git_sandbox, git_repo_with_commit, git_daemon, git_base_dir, git_author +): + repo_path = git_repo_with_commit + remote_url = git_daemon["remote_url"] + clone_path = f"{git_base_dir}/clone" + + git_sandbox.git.remote_add(repo_path, "origin", remote_url) + git_sandbox.git.push( + repo_path, + remote="origin", + branch="main", + set_upstream=True, + ) + git_sandbox.git.clone(remote_url, clone_path) + + git_sandbox.files.write(f"{repo_path}/README.md", "hello\nmore\n") + author_name, author_email = git_author + git_sandbox.git.add(repo_path, all=True) + git_sandbox.git.commit( + repo_path, + message="Update README", + author_name=author_name, + author_email=author_email, + ) + git_sandbox.git.push(repo_path) + + git_sandbox.git.pull(clone_path) + contents = git_sandbox.files.read(f"{clone_path}/README.md") + assert "more" in contents + + +@pytest.mark.skip_debug() +def test_pull_without_upstream_warns(git_sandbox, git_repo_with_commit, git_daemon): + repo_path = git_repo_with_commit + remote_url = git_daemon["remote_url"] + + git_sandbox.git.remote_add(repo_path, "origin", remote_url) + + with pytest.raises(GitUpstreamException) as exc: + git_sandbox.git.pull(repo_path) + + assert "no upstream branch is configured" in str(exc.value).lower()