Skip to content
Merged
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
46 changes: 46 additions & 0 deletions src/__tests__/clone.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,49 @@ describe('shallowClone — windowsHide', () => {
expect(result.cloneMethod).toBe('https-anonymous');
});
});

describe('shallowClone — split UTF-8 output', () => {
let tmp: string;

beforeEach(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-clone-'));
vi.spyOn(log, 'debug').mockImplementation(() => {});
vi.mocked(spawn).mockReset();
});

afterEach(() => {
vi.restoreAllMocks();
fs.rmSync(tmp, { recursive: true, force: true });
});

/** A `git` that writes `output` to stdout in two chunks split at `splitAt`. */
function makeChunkedProcess(output: Buffer, splitAt: number): ChildProcess {
let onData: ((chunk: Buffer) => void) | undefined;
return {
stdout: { on: vi.fn((_event: string, cb: (chunk: Buffer) => void) => { onData = cb; }) },
stderr: { on: vi.fn() },
on: vi.fn((event: string, cb: (...args: unknown[]) => void) => {
if (event !== 'close') return;
void Promise.resolve().then(() => {
onData?.(output.subarray(0, splitAt));
onData?.(output.subarray(splitAt));
cb(0);
});
}),
kill: vi.fn(),
} as unknown as ChildProcess;
}

it('keeps a branch name whose character straddles two chunks intact', async () => {
const branch = 'feature/会话';
vi.mocked(spawn).mockImplementation(((_cmd: string, args: string[]) => {
const output = args.includes('--abbrev-ref') ? `${branch}\n` : '0123456789abcdef\n';
const bytes = Buffer.from(output);
return makeChunkedProcess(bytes, bytes.indexOf(Buffer.from('会')) + 1);
}) as unknown as typeof spawn);

const result = await shallowClone('https://example.com/org/repo.git', path.join(tmp, 'repo'), 'git');

expect(result.branch).toBe(branch);
});
});
41 changes: 41 additions & 0 deletions src/__tests__/exec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,4 +107,45 @@ describe('execCommand', () => {
];
expect(options.windowsHide).toBe(true);
});

// Pipe reads split at byte offsets, so one character can arrive in two chunks.
const text = '会话标题 café 😀';
const splitInside = (s: string, char: string): [Buffer, Buffer] => {
const bytes = Buffer.from(s);
const at = bytes.indexOf(Buffer.from(char)) + 1;
return [bytes.subarray(0, at), bytes.subarray(at)];
};

it('keeps a multi-byte character that straddles two chunks intact', async () => {
const { proc, emit } = makeMockProcess();
vi.mocked(spawn).mockReturnValue(proc as unknown as ChildProcess);

const promise = execCommand('opencode', ['session', 'list']);
for (const chunk of splitInside(text, '标')) emit.stdout(chunk);
for (const chunk of splitInside(text, '😀')) emit.stderr(chunk);
emit.close(0);

await expect(promise).resolves.toEqual({ stdout: text, stderr: text, code: 0 });
});

it('streams the split character intact when stream is set', async () => {
const { proc, emit } = makeMockProcess();
vi.mocked(spawn).mockReturnValue(proc as unknown as ChildProcess);
const out = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
const err = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);

try {
const promise = execCommand('opencode', ['session', 'list'], { stream: true });
for (const chunk of splitInside(text, 'é')) emit.stdout(chunk);
for (const chunk of splitInside(text, '会')) emit.stderr(chunk);
emit.close(0);
await promise;

expect(out.mock.calls.map(([chunk]) => chunk).join('')).toBe(text);
expect(err.mock.calls.map(([chunk]) => chunk).join('')).toBe(text);
} finally {
out.mockRestore();
err.mockRestore();
}
});
});
14 changes: 11 additions & 3 deletions src/clone.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { spawn } from 'node:child_process';
import { StringDecoder } from 'node:string_decoder';

import fs from 'fs-extra';

Expand Down Expand Up @@ -97,9 +98,12 @@ function runCommand(

let stdout = '';
let stderr = '';
// Same split-chunk UTF-8 handling as execCommand in utils/exec.ts.
const stdoutDecoder = new StringDecoder('utf8');
const stderrDecoder = new StringDecoder('utf8');

child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString(); });
child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString(); });
child.stdout.on('data', (chunk: Buffer) => { stdout += stdoutDecoder.write(chunk); });
child.stderr.on('data', (chunk: Buffer) => { stderr += stderrDecoder.write(chunk); });

const timer = setTimeout(() => {
child.kill();
Expand All @@ -108,7 +112,11 @@ function runCommand(

child.on('close', (code) => {
clearTimeout(timer);
resolve({ stdout, stderr, code: code ?? 1 });
resolve({
stdout: stdout + stdoutDecoder.end(),
stderr: stderr + stderrDecoder.end(),
code: code ?? 1,
});
});

child.on('error', (err) => {
Expand Down
27 changes: 20 additions & 7 deletions src/utils/exec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import spawn from 'cross-spawn';
import type { ChildProcess } from 'node:child_process';
import type { Readable } from 'node:stream';
import { StringDecoder } from 'node:string_decoder';

export interface ExecOptions {
cwd?: string;
Expand Down Expand Up @@ -45,13 +46,17 @@ export const execCommand: CommandExecutor = (
callback();
};

// Pipe reads split at byte offsets, so a multi-byte character can straddle
// two chunks; one decoder per stream carries the partial bytes across.
const stdoutDecoder = new StringDecoder('utf8');
const stderrDecoder = new StringDecoder('utf8');
child.stdout?.on('data', (chunk: Buffer) => {
const text = chunk.toString();
const text = stdoutDecoder.write(chunk);
stdout += text;
if (options.stream) process.stdout.write(text);
});
child.stderr?.on('data', (chunk: Buffer) => {
const text = chunk.toString();
const text = stderrDecoder.write(chunk);
stderr += text;
if (options.stream) process.stderr.write(text);
});
Expand All @@ -64,11 +69,19 @@ export const execCommand: CommandExecutor = (
}, timeoutMs);

child.on('error', (error) => finish(() => reject(error)));
child.on('close', (code) => finish(() => resolve({
stdout,
stderr,
code: code ?? 1,
})));
child.on('close', (code) => finish(() => {
const stdoutRest = stdoutDecoder.end();
const stderrRest = stderrDecoder.end();
if (options.stream) {
process.stdout.write(stdoutRest);
process.stderr.write(stderrRest);
}
resolve({
stdout: stdout + stdoutRest,
stderr: stderr + stderrRest,
code: code ?? 1,
});
}));
});

export async function probeBinary(
Expand Down
Loading