diff --git a/packages/js-sdk/src/index.ts b/packages/js-sdk/src/index.ts index e20436ed2f..a4f5616218 100644 --- a/packages/js-sdk/src/index.ts +++ b/packages/js-sdk/src/index.ts @@ -107,6 +107,8 @@ export type { GitConfigOpts, GitConfigScope, GitBranches, + GitCommit, + GitLogOpts, GitFileStatus, GitStatus, } from './sandbox/git' diff --git a/packages/js-sdk/src/sandbox/git/index.ts b/packages/js-sdk/src/sandbox/git/index.ts index f50a864c9e..7c84c42eed 100644 --- a/packages/js-sdk/src/sandbox/git/index.ts +++ b/packages/js-sdk/src/sandbox/git/index.ts @@ -14,12 +14,15 @@ import { buildUpstreamErrorMessage, GitBranches, GitConfigScope, + GitCommit, GitStatus, + GIT_LOG_FORMAT, getRepoPathForScope, getScopeFlag, isAuthFailure, isMissingUpstream, parseGitBranches, + parseGitLog, parseGitStatus, stripCredentials, deriveRepoDirFromUrl, @@ -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. @@ -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 { + 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. * @@ -1048,6 +1075,7 @@ export class Git { export type { GitBranches, + GitCommit, GitConfigScope, GitFileStatus, GitStatus, diff --git a/packages/js-sdk/src/sandbox/git/utils.ts b/packages/js-sdk/src/sandbox/git/utils.ts index 6df7fbc888..37126a4bb1 100644 --- a/packages/js-sdk/src/sandbox/git/utils.ts +++ b/packages/js-sdk/src/sandbox/git/utils.ts @@ -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. * diff --git a/packages/js-sdk/tests/sandbox/git/log.test.ts b/packages/js-sdk/tests/sandbox/git/log.test.ts new file mode 100644 index 0000000000..00c849a1bf --- /dev/null +++ b/packages/js-sdk/tests/sandbox/git/log.test.ts @@ -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') +}) diff --git a/packages/python-sdk/e2b/__init__.py b/packages/python-sdk/e2b/__init__.py index c8b65aa74a..4f6a11aa70 100644 --- a/packages/python-sdk/e2b/__init__.py +++ b/packages/python-sdk/e2b/__init__.py @@ -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 @@ -175,6 +175,7 @@ "SandboxState", "SandboxMetrics", "GitStatus", + "GitCommit", "GitBranches", "GitFileStatus", "GitResetMode", diff --git a/packages/python-sdk/e2b/sandbox/_git/__init__.py b/packages/python-sdk/e2b/sandbox/_git/__init__.py index e62ad9f6ca..568f29d053 100644 --- a/packages/python-sdk/e2b/sandbox/_git/__init__.py +++ b/packages/python-sdk/e2b/sandbox/_git/__init__.py @@ -18,6 +18,7 @@ build_remote_set_url_args, build_reset_args, build_restore_args, + build_log_args, build_status_args, shell_escape, ) @@ -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, @@ -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", @@ -79,6 +84,7 @@ "with_credentials", "ClonePlan", "GitBranches", + "GitCommit", "GitFileStatus", "GitResetMode", "GitStatus", diff --git a/packages/python-sdk/e2b/sandbox/_git/args.py b/packages/python-sdk/e2b/sandbox/_git/args.py index 9d214b2e71..405fbf6ec3 100644 --- a/packages/python-sdk/e2b/sandbox/_git/args.py +++ b/packages/python-sdk/e2b/sandbox/_git/args.py @@ -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. diff --git a/packages/python-sdk/e2b/sandbox/_git/parse.py b/packages/python-sdk/e2b/sandbox/_git/parse.py index e6cea693af..b781ec723c 100644 --- a/packages/python-sdk/e2b/sandbox/_git/parse.py +++ b/packages/python-sdk/e2b/sandbox/_git/parse.py @@ -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]: @@ -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 diff --git a/packages/python-sdk/e2b/sandbox/_git/types.py b/packages/python-sdk/e2b/sandbox/_git/types.py index 35006e2108..2ba5584f91 100644 --- a/packages/python-sdk/e2b/sandbox/_git/types.py +++ b/packages/python-sdk/e2b/sandbox/_git/types.py @@ -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: """ diff --git a/packages/python-sdk/e2b/sandbox_async/git.py b/packages/python-sdk/e2b/sandbox_async/git.py index b57fb5a76e..69454ff701 100644 --- a/packages/python-sdk/e2b/sandbox_async/git.py +++ b/packages/python-sdk/e2b/sandbox_async/git.py @@ -9,6 +9,7 @@ from e2b.sandbox._git import ( GitBranches, GitResetMode, + GitCommit, GitStatus, build_add_args, build_auth_error_message, @@ -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, @@ -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, diff --git a/packages/python-sdk/e2b/sandbox_sync/git.py b/packages/python-sdk/e2b/sandbox_sync/git.py index 39edb5c04c..149d3f08c1 100644 --- a/packages/python-sdk/e2b/sandbox_sync/git.py +++ b/packages/python-sdk/e2b/sandbox_sync/git.py @@ -3,6 +3,7 @@ from e2b.sandbox._git import ( GitBranches, GitResetMode, + GitCommit, GitStatus, build_add_args, build_auth_error_message, @@ -24,11 +25,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, @@ -465,6 +468,39 @@ def status( ) return parse_git_status(result.stdout) + 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 = self._run_git( + build_log_args(max_count), + path, + envs, + user, + cwd, + timeout, + request_timeout, + ) + return parse_git_log(result.stdout) + def branches( self, path: str, diff --git a/packages/python-sdk/tests/shared/git/test_log.py b/packages/python-sdk/tests/shared/git/test_log.py new file mode 100644 index 0000000000..56f993f0d8 --- /dev/null +++ b/packages/python-sdk/tests/shared/git/test_log.py @@ -0,0 +1,46 @@ +from e2b.sandbox._git import build_log_args, parse_git_log +from e2b.sandbox._git.types import GitCommit + +_FORMAT = "log" +_PRETTY = "--pretty=format:%H%x1f%an%x1f%ae%x1f%aI%x1f%s" + + +def test_build_log_args_default(): + assert build_log_args() == [_FORMAT, _PRETTY] + + +def test_build_log_args_with_max_count(): + assert build_log_args(5) == [_FORMAT, _PRETTY, "-n", "5"] + + +def test_parse_git_log_parses_commits(): + output = ( + "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" + ) + commits = parse_git_log(output) + + assert commits == [ + GitCommit( + hash="abc123", + author_name="Ada Lovelace", + author_email="ada@example.com", + date="2026-07-01T10:00:00+00:00", + message="Initial commit", + ), + GitCommit( + hash="def456", + author_name="Alan Turing", + author_email="alan@example.com", + date="2026-07-02T12:30:00+00:00", + message="Add feature", + ), + ] + + +def test_parse_git_log_empty_output(): + assert parse_git_log("") == [] + + +def test_parse_git_log_skips_malformed_lines(): + assert parse_git_log("no-separators-here") == []