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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/js-sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ export type {
GitConfigOpts,
GitConfigScope,
GitBranches,
GitCommit,
GitLogOpts,
GitFileStatus,
GitStatus,
} from './sandbox/git'
Expand Down
28 changes: 28 additions & 0 deletions packages/js-sdk/src/sandbox/git/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,15 @@ import {
buildUpstreamErrorMessage,
GitBranches,
GitConfigScope,
GitCommit,
GitStatus,
GIT_LOG_FORMAT,
getRepoPathForScope,
getScopeFlag,
isAuthFailure,
isMissingUpstream,
parseGitBranches,
parseGitLog,
parseGitStatus,
stripCredentials,
deriveRepoDirFromUrl,
Expand All @@ -44,6 +47,13 @@ export interface GitRequestOpts
/**
* Options for cloning a repository.
*/
export interface GitLogOpts extends GitRequestOpts {
/**
* Maximum number of commits to return (newest first).
*/
maxCount?: number
}

export interface GitCloneOpts extends GitRequestOpts {
/**
* Destination path for the clone.
Expand Down Expand Up @@ -471,6 +481,23 @@ export class Git {
return parseGitStatus(result.stdout)
}

/**
* Get the commit history of a repository.
*
* @param path Repository path.
* @param opts Command execution options, including `maxCount`.
* @returns List of parsed commits, newest first.
*/
async log(path: string, opts?: GitLogOpts): Promise<GitCommit[]> {
const { maxCount, ...rest } = opts ?? {}
const args = ['log', `--pretty=format:${GIT_LOG_FORMAT}`]
if (maxCount != null) {
args.push('-n', String(maxCount))
}
const result = await this.runGit(args, path, rest)
return parseGitLog(result.stdout)
}

/**
* List branches in a repository.
*
Expand Down Expand Up @@ -1048,6 +1075,7 @@ export class Git {

export type {
GitBranches,
GitCommit,
GitConfigScope,
GitFileStatus,
GitStatus,
Expand Down
49 changes: 49 additions & 0 deletions packages/js-sdk/src/sandbox/git/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,55 @@ export interface GitBranches {
currentBranch?: string
}

/**
* Parsed entry from git log.
*/
export interface GitCommit {
/** Full commit hash. */
hash: string
/** Commit author name. */
authorName: string
/** Commit author email. */
authorEmail: string
/** Author date in strict ISO 8601 format. */
date: string
/** Commit subject line. */
message: string
}

/**
* Pretty format for `git log`, using a unit separator between fields so
* subjects containing spaces parse cleanly.
*/
export const GIT_LOG_FORMAT = '%H%x1f%an%x1f%ae%x1f%aI%x1f%s'

/**
* Parse machine-readable `git log` output into commit entries.
*
* @param output Raw stdout produced with the pipeline log format.
* @returns List of parsed commits, newest first.
*/
export function parseGitLog(output: string): GitCommit[] {
const commits: GitCommit[] = []
for (const line of output.split('\n')) {
if (!line.trim()) {
continue
}
const parts = line.split('\x1f')
if (parts.length < 5) {
continue
}
commits.push({
hash: parts[0],
authorName: parts[1],
authorEmail: parts[2],
date: parts[3],
message: parts[4],
})
}
return commits
}

/**
* Add HTTP(S) credentials to a Git URL.
*
Expand Down
46 changes: 46 additions & 0 deletions packages/js-sdk/tests/sandbox/git/log.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { test, expect } from 'vitest'

import { Git } from '../../../src/sandbox/git'
import type { Commands } from '../../../src/sandbox/commands'
import { parseGitLog } from '../../../src/sandbox/git/utils'

const SAMPLE =
'abc123\x1fAda Lovelace\x1fada@example.com\x1f2026-07-01T10:00:00+00:00\x1fInitial commit\n' +
'def456\x1fAlan Turing\x1falan@example.com\x1f2026-07-02T12:30:00+00:00\x1fAdd feature'

test('parseGitLog parses commits', () => {
expect(parseGitLog(SAMPLE)).toEqual([
{
hash: 'abc123',
authorName: 'Ada Lovelace',
authorEmail: 'ada@example.com',
date: '2026-07-01T10:00:00+00:00',
message: 'Initial commit',
},
{
hash: 'def456',
authorName: 'Alan Turing',
authorEmail: 'alan@example.com',
date: '2026-07-02T12:30:00+00:00',
message: 'Add feature',
},
])
})

test('parseGitLog handles empty and malformed output', () => {
expect(parseGitLog('')).toEqual([])
expect(parseGitLog('no-separators-here')).toEqual([])
})

test('git.log runs git log and returns parsed commits', async () => {
const commands = {
run: async () => ({ stdout: SAMPLE, stderr: '', exitCode: 0 }),
} as unknown as Commands

const git = new Git(commands)
const commits = await git.log('/repo', { maxCount: 5 })

expect(commits).toHaveLength(2)
expect(commits[0].message).toBe('Initial commit')
expect(commits[1].authorName).toBe('Alan Turing')
})
3 changes: 2 additions & 1 deletion packages/python-sdk/e2b/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@
FilesystemEvent,
FilesystemEventType,
)
from .sandbox._git import GitBranches, GitFileStatus, GitResetMode, GitStatus
from .sandbox._git import GitBranches, GitCommit, GitFileStatus, GitResetMode, GitStatus
from .sandbox_sync.git import Git
from .sandbox.network import ALL_TRAFFIC
from .sandbox.signature import get_signature
Expand Down Expand Up @@ -175,6 +175,7 @@
"SandboxState",
"SandboxMetrics",
"GitStatus",
"GitCommit",
"GitBranches",
"GitFileStatus",
"GitResetMode",
Expand Down
6 changes: 6 additions & 0 deletions packages/python-sdk/e2b/sandbox/_git/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
build_remote_set_url_args,
build_reset_args,
build_restore_args,
build_log_args,
build_status_args,
shell_escape,
)
Expand All @@ -33,12 +34,14 @@
from e2b.sandbox._git.parse import (
derive_repo_dir_from_url,
parse_git_branches,
parse_git_log,
parse_git_status,
parse_remote_url,
)
from e2b.sandbox._git.types import (
ClonePlan,
GitBranches,
GitCommit,
GitFileStatus,
GitResetMode,
GitStatus,
Expand All @@ -65,12 +68,14 @@
"build_remote_set_url_args",
"build_reset_args",
"build_restore_args",
"build_log_args",
"build_status_args",
"build_upstream_error_message",
"derive_repo_dir_from_url",
"is_auth_failure",
"is_missing_upstream",
"parse_git_branches",
"parse_git_log",
"parse_git_status",
"parse_remote_url",
"resolve_config_scope",
Expand All @@ -79,6 +84,7 @@
"with_credentials",
"ClonePlan",
"GitBranches",
"GitCommit",
"GitFileStatus",
"GitResetMode",
"GitStatus",
Expand Down
17 changes: 17 additions & 0 deletions packages/python-sdk/e2b/sandbox/_git/args.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,23 @@ def build_status_args() -> List[str]:
return ["status", "--porcelain=1", "-b"]


# Unit separator between fields, so subjects containing spaces parse cleanly.
GIT_LOG_FORMAT = "%H%x1f%an%x1f%ae%x1f%aI%x1f%s"


def build_log_args(max_count: Optional[int] = None) -> List[str]:
"""
Build arguments for ``git log`` using a machine-readable format.

:param max_count: Maximum number of commits to return (newest first)
:return: Command arguments for git log
"""
args = ["log", f"--pretty=format:{GIT_LOG_FORMAT}"]
if max_count is not None:
args.extend(["-n", str(max_count)])
return args


def build_branches_args() -> List[str]:
"""
Build arguments for a git branch listing command.
Expand Down
31 changes: 30 additions & 1 deletion packages/python-sdk/e2b/sandbox/_git/parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from urllib.parse import urlparse

from e2b.exceptions import InvalidArgumentException
from e2b.sandbox._git.types import GitBranches, GitFileStatus, GitStatus
from e2b.sandbox._git.types import GitBranches, GitCommit, GitFileStatus, GitStatus


def derive_repo_dir_from_url(url: str) -> Optional[str]:
Expand Down Expand Up @@ -220,3 +220,32 @@ def parse_remote_url(output: str, remote: str) -> str:
f'Remote "{remote}" URL not found in repository.'
)
return url


_GIT_LOG_FIELD_SEPARATOR = "\x1f"


def parse_git_log(output: str) -> List[GitCommit]:
"""
Parse machine-readable ``git log`` output into commit entries.

:param output: Raw stdout produced with the pipeline log format
:return: List of parsed commits, newest first
"""
commits: List[GitCommit] = []
for line in output.split("\n"):
if not line.strip():
continue
parts = line.split(_GIT_LOG_FIELD_SEPARATOR)
if len(parts) < 5:
continue
commits.append(
GitCommit(
hash=parts[0],
author_name=parts[1],
author_email=parts[2],
date=parts[3],
message=parts[4],
)
)
return commits
19 changes: 19 additions & 0 deletions packages/python-sdk/e2b/sandbox/_git/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,25 @@ class GitBranches:
current_branch: Optional[str]


@dataclass
class GitCommit:
"""
Parsed entry from git log.

:param hash: Full commit hash
:param author_name: Commit author name
:param author_email: Commit author email
:param date: Author date in strict ISO 8601 format
:param message: Commit subject line
"""

hash: str
author_name: str
author_email: str
date: str
message: str


@dataclass
class ClonePlan:
"""
Expand Down
36 changes: 36 additions & 0 deletions packages/python-sdk/e2b/sandbox_async/git.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from e2b.sandbox._git import (
GitBranches,
GitResetMode,
GitCommit,
GitStatus,
build_add_args,
build_auth_error_message,
Expand All @@ -30,11 +31,13 @@
build_remote_set_url_args,
build_reset_args,
build_restore_args,
build_log_args,
build_status_args,
build_upstream_error_message,
is_auth_failure,
is_missing_upstream,
parse_git_branches,
parse_git_log,
parse_git_status,
parse_remote_url,
resolve_config_scope,
Expand Down Expand Up @@ -473,6 +476,39 @@ async def status(
)
return parse_git_status(result.stdout)

async def log(
self,
path: str,
max_count: Optional[int] = None,
envs: Optional[Dict[str, str]] = None,
user: Optional[str] = None,
cwd: Optional[str] = None,
timeout: Optional[float] = None,
request_timeout: Optional[float] = None,
) -> List[GitCommit]:
"""
Get the commit history of a repository.

:param path: Repository path
:param max_count: Maximum number of commits to return (newest first)
: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: List of parsed commits, newest first
"""
result = await self._run_git(
build_log_args(max_count),
path,
envs,
user,
cwd,
timeout,
request_timeout,
)
return parse_git_log(result.stdout)

async def branches(
self,
path: str,
Expand Down
Loading