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
62 changes: 14 additions & 48 deletions extensions/git/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import {
SandboxExtension,
type SandboxLike
} from '@cloudflare/sandbox/extensions';
import { redactCommand } from '@repo/shared';
import { redactCommand, type SandboxCommand } from '@repo/shared';
import {
ErrorCode,
type ErrorResponse,
Expand All @@ -55,17 +55,15 @@ import type {
GitCheckoutOptions,
GitCheckoutResult,
GitExtensionOptions,
GitHostAuth,
GitSessionOptions
GitHostAuth
} from './types.js';

export type {
GitAuthConfig,
GitCheckoutOptions,
GitCheckoutResult,
GitExtensionOptions,
GitHostAuth,
GitSessionOptions
GitHostAuth
} from './types.js';
export { withGit as default };

Expand Down Expand Up @@ -131,10 +129,8 @@ export class Git extends SandboxExtension {

await this.#configureAuth(repoUrl, options.auth);

const sessionId = this.#sessionId(options);
const cloneResult = await this.#exec(
buildCloneArgs(repoUrl, targetDir, options),
sessionId,
undefined,
cloneTimeoutMs + CLONE_PROCESS_TIMEOUT_BUFFER_MS
);
Expand Down Expand Up @@ -178,7 +174,6 @@ export class Git extends SandboxExtension {
// Query the branch actually checked out rather than assuming.
const branchResult = await this.#exec(
buildGetCurrentBranchArgs(),
sessionId,
targetDir
);
const branch =
Expand All @@ -196,11 +191,7 @@ export class Git extends SandboxExtension {
}

/** Check out an existing branch in a cloned repository. */
async checkoutBranch(
repoPath: string,
branch: string,
options: GitSessionOptions = {}
): Promise<void> {
async checkoutBranch(repoPath: string, branch: string): Promise<void> {
const pathValidation = validatePath(repoPath);
if (!pathValidation.isValid) {
this.#throwValidation('repoPath', pathValidation.errors, 'INVALID_PATH');
Expand All @@ -223,11 +214,7 @@ export class Git extends SandboxExtension {
);
}

const result = await this.#exec(
buildCheckoutArgs(branch),
this.#sessionId(options),
repoPath
);
const result = await this.#exec(buildCheckoutArgs(branch), repoPath);

if (result.exitCode !== 0) {
const code = determineErrorCode(
Expand All @@ -251,20 +238,13 @@ export class Git extends SandboxExtension {
}

/** Return the current branch of a cloned repository. */
async getCurrentBranch(
repoPath: string,
options: GitSessionOptions = {}
): Promise<string> {
async getCurrentBranch(repoPath: string): Promise<string> {
const pathValidation = validatePath(repoPath);
if (!pathValidation.isValid) {
this.#throwValidation('repoPath', pathValidation.errors, 'INVALID_PATH');
}

const result = await this.#exec(
buildGetCurrentBranchArgs(),
this.#sessionId(options),
repoPath
);
const result = await this.#exec(buildGetCurrentBranchArgs(), repoPath);

if (result.exitCode !== 0) {
this.#throwError(
Expand All @@ -288,20 +268,13 @@ export class Git extends SandboxExtension {
}

/** List local and remote branches of a cloned repository. */
async listBranches(
repoPath: string,
options: GitSessionOptions = {}
): Promise<string[]> {
async listBranches(repoPath: string): Promise<string[]> {
const pathValidation = validatePath(repoPath);
if (!pathValidation.isValid) {
this.#throwValidation('repoPath', pathValidation.errors, 'INVALID_PATH');
}

const result = await this.#exec(
buildListBranchesArgs(),
this.#sessionId(options),
repoPath
);
const result = await this.#exec(buildListBranchesArgs(), repoPath);

if (result.exitCode !== 0) {
this.#throwError(
Expand Down Expand Up @@ -386,23 +359,16 @@ export class Git extends SandboxExtension {
return hosts;
}

#sessionId(options: GitSessionOptions): string | undefined {
return options.sessionId;
}

async #exec(
command: string[],
sessionId: string | undefined,
command: SandboxCommand,
cwd?: string,
timeout?: number
): Promise<ExecOutcome> {
const result = await this.exec(command, {
...(sessionId !== undefined && { sessionId }),
const process = await this.exec(command, {
...(cwd !== undefined && { cwd }),
...(timeout !== undefined && { timeout }),
stdout: 'pipe',
stderr: 'pipe'
}).output({ encoding: 'utf8' });
...(timeout !== undefined && { timeout })
});
const result = await process.output({ encoding: 'utf8' });
return {
stdout: result.stdout,
stderr: result.stderr,
Expand Down
16 changes: 10 additions & 6 deletions extensions/git/src/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@
* `GitManager` so the extension can drive everything through `exec`.
*/

import { DEFAULT_GIT_CLONE_TIMEOUT_MS, extractRepoName } from '@repo/shared';
import {
DEFAULT_GIT_CLONE_TIMEOUT_MS,
extractRepoName,
type SandboxCommand
} from '@repo/shared';
import { ErrorCode } from '@repo/shared/errors';
import type { GitCheckoutOptions } from './types.js';

Expand Down Expand Up @@ -43,10 +47,10 @@ export function buildCloneArgs(
repoUrl: string,
targetDir: string,
options: GitCheckoutOptions = {}
): string[] {
): SandboxCommand {
const timeoutMs = options.cloneTimeoutMs ?? DEFAULT_GIT_CLONE_TIMEOUT_MS;
const timeoutSeconds = gitCloneTimeoutSeconds(timeoutMs);
const args = [
const args: [string, ...string[]] = [
'timeout',
'-k',
String(GIT_CLONE_KILL_GRACE_SECONDS),
Expand All @@ -73,15 +77,15 @@ export function buildCloneArgs(
return args;
}

export function buildCheckoutArgs(branch: string): string[] {
export function buildCheckoutArgs(branch: string): SandboxCommand {
return ['git', 'checkout', branch];
}

export function buildGetCurrentBranchArgs(): string[] {
export function buildGetCurrentBranchArgs(): SandboxCommand {
return ['git', 'branch', '--show-current'];
}

export function buildListBranchesArgs(): string[] {
export function buildListBranchesArgs(): SandboxCommand {
return ['git', 'branch', '-a'];
}

Expand Down
15 changes: 1 addition & 14 deletions extensions/git/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,21 +32,8 @@ export interface GitExtensionOptions {
auth?: GitAuthConfig;
}

/** Options shared by every git extension method. */
export interface GitSessionOptions {
/**
* Session to run the git command in. Omit to run sessionless (the command
* runs in a fresh, non-persistent shell). Sessionless commands still inherit
* the sandbox-level environment variables (e.g. tokens, proxy settings), so
* auth and egress configured on the sandbox keep working. Pass a session id
* to run inside an existing session so its working directory and environment
* carry over instead.
*/
sessionId?: string;
}

/** Options for cloning a repository. */
export interface GitCheckoutOptions extends GitSessionOptions {
export interface GitCheckoutOptions {
/** Branch (or tag) to check out after cloning. */
branch?: string;
/** Directory to clone into. Defaults to `/workspace/<repoName>`. */
Expand Down
100 changes: 39 additions & 61 deletions extensions/git/tests/git.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type {
SandboxExecOptions,
SandboxExecStringOutput,
SandboxProcessPromise
ExecOptions,
ProcessOutput,
SandboxCommand,
SandboxProcess
} from '@repo/shared';
import { describe, expect, it, vi } from 'vitest';
import {
Expand All @@ -23,32 +24,39 @@ import {

type ExecResult = { stdout: string; stderr: string; exitCode: number };

type GitCommand = string | string[];

function outputFor(
command: GitCommand,
result: ExecResult
): SandboxExecStringOutput {
function outputFor(result: ExecResult): ProcessOutput<string> {
return {
...result,
success: result.exitCode === 0,
duration: 1,
command: Array.isArray(command) ? command.join(' ') : command,
timestamp: new Date().toISOString()
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.exitCode,
timedOut: false,
truncated: false
};
}

function processFor(result: ExecResult): SandboxProcess {
return {
id: 'git-test-process',
pid: 1234,
exitCode: Promise.resolve(result.exitCode),
status: vi.fn(),
logs: vi.fn(),
waitForLog: vi.fn(),
waitForExit: vi.fn(),
output: vi.fn(async () => outputFor(result)),
waitForPort: vi.fn(),
kill: vi.fn()
} as unknown as SandboxProcess;
}

function createGit(
execImpl: (command: GitCommand, options?: SandboxExecOptions) => ExecResult,
execImpl: (command: SandboxCommand, options?: ExecOptions) => ExecResult,
envVars?: Record<string, string>,
registerGitAuthInterceptor?: SandboxLike['registerGitAuthInterceptor']
) {
const exec = vi.fn((command: GitCommand, options?: SandboxExecOptions) => {
const result = execImpl(command, options);
return {
output: vi.fn(async () => outputFor(command, result))
} as unknown as SandboxProcessPromise;
});
const exec = vi.fn(async (command: SandboxCommand, options?: ExecOptions) =>
processFor(execImpl(command, options))
);
const sandbox = {
client: { commands: { execute: vi.fn() } },
exec,
Expand Down Expand Up @@ -151,31 +159,23 @@ describe('Git extension', () => {
expect.arrayContaining(['git', 'clone'])
);
expect(exec.mock.calls[0][1]).toEqual(
expect.objectContaining({ stdout: 'pipe', stderr: 'pipe' })
expect.objectContaining({ timeout: 610_000 })
);
});

it('runs in the provided session and target dir', async () => {
it('runs branch detection in the provided target dir', async () => {
const { git, exec } = createGit(() => ({
stdout: 'main\n',
stderr: '',
exitCode: 0
}));

await git.checkout('https://github.com/owner/repo.git', {
targetDir: '/work/app',
sessionId: 'sess-1'
targetDir: '/work/app'
});

expect(exec.mock.calls[0][1]).toEqual(
expect.objectContaining({ sessionId: 'sess-1' })
);
// Branch query runs with cwd set to the target dir.
expect(exec.mock.calls[1][1]).toEqual({
cwd: '/work/app',
sessionId: 'sess-1',
stdout: 'pipe',
stderr: 'pipe'
cwd: '/work/app'
});
});

Expand Down Expand Up @@ -251,12 +251,9 @@ describe('Git extension', () => {

it('registers git auth interception when auth matches the checkout host', async () => {
const registerGitAuthInterceptor = vi.fn(async () => {});
const exec = vi.fn((command: GitCommand) => {
const result = { stdout: 'main\n', stderr: '', exitCode: 0 };
return {
output: vi.fn(async () => outputFor(command, result))
} as unknown as SandboxProcessPromise;
});
const exec = vi.fn(async () =>
processFor({ stdout: 'main\n', stderr: '', exitCode: 0 })
);
const sandbox = {
client: { commands: { execute: vi.fn() } },
exec,
Expand Down Expand Up @@ -319,12 +316,9 @@ describe('Git extension', () => {
const configuredGit = withGit(
{
client: { commands: { execute: vi.fn() } },
exec: vi.fn((command: GitCommand) => {
const result = { stdout: 'main\n', stderr: '', exitCode: 0 };
return {
output: vi.fn(async () => outputFor(command, result))
} as unknown as SandboxProcessPromise;
}),
exec: vi.fn(async () =>
processFor({ stdout: 'main\n', stderr: '', exitCode: 0 })
),
registerGitAuthInterceptor
} as unknown as SandboxLike,
{ auth: { github: { token: 'secret-token' } } }
Expand Down Expand Up @@ -352,20 +346,4 @@ describe('Git extension', () => {
).rejects.toThrow(/exporting ContainerProxy/);
expect(exec).not.toHaveBeenCalled();
});

it('does not inject sandbox env vars when a session is provided', async () => {
const { git, exec } = createGit(
() => ({ stdout: 'main\n', stderr: '', exitCode: 0 }),
{ GITHUB_TOKEN: 'tok' }
);

await git.checkout('https://github.com/owner/repo.git', {
sessionId: 'sess-1'
});

for (const call of exec.mock.calls) {
expect((call[1] as { sessionId?: string }).sessionId).toBe('sess-1');
expect((call[1] as { env?: Record<string, string> }).env).toBeUndefined();
}
});
});
23 changes: 23 additions & 0 deletions extensions/interpreter/src/sidecar/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'bun:test';
import {
isMissingJavaScriptExecutorError,
summarizeSpawnOutput
} from './config';

describe('sidecar pool config helpers', () => {
it('summarizes spawn output for bounded logs', () => {
const summary = summarizeSpawnOutput(`line 1\n\n${'x'.repeat(300)}`);

expect(summary).not.toContain('\n');
expect(summary.length).toBeLessThanOrEqual(240);
});

it('classifies missing JavaScript executor startup errors', () => {
expect(
isMissingJavaScriptExecutorError(
new Error('JavaScript executor binary not found. Checked: path')
)
).toBe(true);
expect(isMissingJavaScriptExecutorError(new Error('other'))).toBe(false);
});
});
Loading
Loading