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
1 change: 1 addition & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"test": "vitest run"
},
"dependencies": {
"node-pty": "^1.1.0",
"open": "^10.1.0",
"ws": "^8.18.0"
},
Expand Down
15 changes: 15 additions & 0 deletions apps/server/src/git.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,21 @@ async function initRepoWithCommit(cwd: string): Promise<void> {
// ── Tests ──

describe("git integration", () => {
describe("runTerminalCommand", () => {
it("caps captured output when maxOutputBytes is exceeded", async () => {
const result = await runTerminalCommand({
command: `node -e "process.stdout.write('x'.repeat(2000))"`,
cwd: process.cwd(),
timeoutMs: 10_000,
maxOutputBytes: 128,
});

expect(result.code).toBe(0);
expect(result.stdout.length).toBeLessThanOrEqual(128);
expect(result.stderr).toContain("output truncated");
});
});

// ── initGitRepo ──

describe("initGitRepo", () => {
Expand Down
85 changes: 77 additions & 8 deletions apps/server/src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,53 @@ import type {
GitListBranchesInput,
GitListBranchesResult,
GitRemoveWorktreeInput,
TerminalCommandInput,
TerminalCommandResult,
} from "@t3tools/contracts";

export interface TerminalCommandInput {
command: string;
cwd: string;
timeoutMs?: number;
maxOutputBytes?: number;
}

export interface TerminalCommandResult {
stdout: string;
stderr: string;
code: number | null;
signal: NodeJS.Signals | null;
timedOut: boolean;
}

const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;

function appendChunkWithinLimit(
target: string,
currentBytes: number,
chunk: Buffer,
maxBytes: number,
): {
next: string;
nextBytes: number;
truncated: boolean;
} {
const remaining = maxBytes - currentBytes;
if (remaining <= 0) {
return { next: target, nextBytes: currentBytes, truncated: true };
}
if (chunk.length <= remaining) {
return {
next: `${target}${chunk.toString()}`,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low

src/git.ts:50 Using chunk.toString() directly can corrupt multi-byte UTF-8 characters that span chunk boundaries. Consider using Node's StringDecoder to handle partial characters correctly.

🚀 Want me to fix this? Reply ex: "fix it for me".

🤖 Prompt for AI
In file apps/server/src/git.ts around line 50:

Using `chunk.toString()` directly can corrupt multi-byte UTF-8 characters that span chunk boundaries. Consider using Node's `StringDecoder` to handle partial characters correctly.

nextBytes: currentBytes + chunk.length,
truncated: false,
};
}
return {
next: `${target}${chunk.subarray(0, remaining).toString()}`,
nextBytes: currentBytes + remaining,
truncated: true,
};
}

/** Spawn git directly with an argv array — no shell, no quoting needed. */
function runGit(args: string[], cwd: string, timeoutMs = 30_000): Promise<TerminalCommandResult> {
return new Promise((resolve, reject) => {
Expand All @@ -25,9 +68,13 @@ function runGit(args: string[], cwd: string, timeoutMs = 30_000): Promise<Termin
stdio: ["ignore", "pipe", "pipe"],
});

const maxOutputBytes = DEFAULT_MAX_OUTPUT_BYTES;
let stdout = "";
let stderr = "";
let stdoutBytes = 0;
let stderrBytes = 0;
let timedOut = false;
let outputTruncated = false;

const timeout = setTimeout(() => {
timedOut = true;
Expand All @@ -38,17 +85,26 @@ function runGit(args: string[], cwd: string, timeoutMs = 30_000): Promise<Termin
}, timeoutMs);

child.stdout?.on("data", (chunk: Buffer) => {
stdout += chunk.toString();
const appended = appendChunkWithinLimit(stdout, stdoutBytes, chunk, maxOutputBytes);
stdout = appended.next;
stdoutBytes = appended.nextBytes;
outputTruncated = outputTruncated || appended.truncated;
});
child.stderr?.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
const appended = appendChunkWithinLimit(stderr, stderrBytes, chunk, maxOutputBytes);
stderr = appended.next;
stderrBytes = appended.nextBytes;
outputTruncated = outputTruncated || appended.truncated;
});
child.on("error", (error) => {
clearTimeout(timeout);
reject(error);
});
child.on("close", (code, signal) => {
clearTimeout(timeout);
if (outputTruncated) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low

src/git.ts:105 When output is truncated, the function appends a warning to stderr but still returns code: 0. Callers like listGitBranches check only code and will silently parse incomplete data. Consider either returning a non-zero code when truncated, adding a truncated field to the result, or throwing an error.

🚀 Want me to fix this? Reply ex: "fix it for me".

🤖 Prompt for AI
In file apps/server/src/git.ts around line 105:

When output is truncated, the function appends a warning to `stderr` but still returns `code: 0`. Callers like `listGitBranches` check only `code` and will silently parse incomplete data. Consider either returning a non-zero code when truncated, adding a `truncated` field to the result, or throwing an error.

stderr = `${stderr}\n[output truncated at ${maxOutputBytes} bytes]`;
}
resolve({ stdout, stderr, code: code ?? null, signal: signal ?? null, timedOut });
});
});
Expand All @@ -57,6 +113,7 @@ function runGit(args: string[], cwd: string, timeoutMs = 30_000): Promise<Termin
export async function runTerminalCommand(
input: TerminalCommandInput,
): Promise<TerminalCommandResult> {
const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
const shellPath =
process.platform === "win32"
? (process.env.ComSpec ?? "cmd.exe")
Expand All @@ -74,7 +131,10 @@ export async function runTerminalCommand(

let stdout = "";
let stderr = "";
let stdoutBytes = 0;
let stderrBytes = 0;
let timedOut = false;
let outputTruncated = false;

const timeout = setTimeout(() => {
timedOut = true;
Expand All @@ -87,11 +147,17 @@ export async function runTerminalCommand(
}, input.timeoutMs ?? 30_000);

child.stdout?.on("data", (chunk: Buffer) => {
stdout += chunk.toString();
const appended = appendChunkWithinLimit(stdout, stdoutBytes, chunk, maxOutputBytes);
stdout = appended.next;
stdoutBytes = appended.nextBytes;
outputTruncated = outputTruncated || appended.truncated;
});

child.stderr?.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
const appended = appendChunkWithinLimit(stderr, stderrBytes, chunk, maxOutputBytes);
stderr = appended.next;
stderrBytes = appended.nextBytes;
outputTruncated = outputTruncated || appended.truncated;
});

child.on("error", (error) => {
Expand All @@ -101,6 +167,9 @@ export async function runTerminalCommand(

child.on("close", (code, signal) => {
clearTimeout(timeout);
if (outputTruncated) {
stderr = `${stderr}\n[output truncated at ${maxOutputBytes} bytes]`;
}
resolve({
stdout,
stderr,
Expand Down Expand Up @@ -138,8 +207,8 @@ export async function listGitBranches(input: GitListBranchesInput): Promise<GitL
let currentPath: string | null = null;
for (const line of worktreeList.stdout.split("\n")) {
if (line.startsWith("worktree ")) {
currentPath = line.slice("worktree ".length);
if (!fs.existsSync(currentPath)) currentPath = null;
const candidatePath = line.slice("worktree ".length);
currentPath = fs.existsSync(candidatePath) ? candidatePath : null;
} else if (line.startsWith("branch refs/heads/") && currentPath) {
worktreeMap.set(line.slice("branch refs/heads/".length), currentPath);
} else if (line === "") {
Expand Down
79 changes: 79 additions & 0 deletions apps/server/src/ptyAdapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import * as nodePty from "node-pty";

export interface PtyExitEvent {
exitCode: number;
signal: number | null;
}

export interface PtyProcess {
readonly pid: number;
write(data: string): void;
resize(cols: number, rows: number): void;
kill(signal?: string): void;
onData(callback: (data: string) => void): () => void;
onExit(callback: (event: PtyExitEvent) => void): () => void;
}

export interface PtySpawnInput {
shell: string;
cwd: string;
cols: number;
rows: number;
env: NodeJS.ProcessEnv;
}

export interface PtyAdapter {
spawn(input: PtySpawnInput): PtyProcess;
}

class NodePtyProcess implements PtyProcess {
constructor(private readonly process: nodePty.IPty) {}

get pid(): number {
return this.process.pid;
}

write(data: string): void {
this.process.write(data);
}

resize(cols: number, rows: number): void {
this.process.resize(cols, rows);
}

kill(signal?: string): void {
this.process.kill(signal);
}

onData(callback: (data: string) => void): () => void {
const disposable = this.process.onData(callback);
return () => {
disposable.dispose();
};
}

onExit(callback: (event: PtyExitEvent) => void): () => void {
const disposable = this.process.onExit((event) => {
callback({
exitCode: event.exitCode,
signal: event.signal ?? null,
});
});
return () => {
disposable.dispose();
};
}
}

export class NodePtyAdapter implements PtyAdapter {
spawn(input: PtySpawnInput): PtyProcess {
const ptyProcess = nodePty.spawn(input.shell, [], {
cwd: input.cwd,
cols: input.cols,
rows: input.rows,
env: input.env,
name: globalThis.process.platform === "win32" ? "xterm-color" : "xterm-256color",
});
return new NodePtyProcess(ptyProcess);
}
}
Loading