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
6 changes: 5 additions & 1 deletion apps/server/src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
* terminal-fallback agents run `t3 acp-mcp-call` per tool call, so their
* startup sits on first-message latency. Both dispatch here before the full
* CLI module graph (seconds of evaluation) loads; everything else defers to
* the real CLI in ./binCli.ts.
* the real CLI in ./binCli.ts. `t3 __run-node-script` runs bundled language
* servers under the single-executable build (see ./nodeScript.ts).
*/
import { isEntrypoint } from "./entrypoint.ts";

Expand All @@ -20,6 +21,9 @@ if (
if (command === "acp-mcp-bridge" || command === "acp-mcp-call") {
const { runAcpMcpCliFastPath } = await import("./mcp/AcpMcpStdioBridge.ts");
await runAcpMcpCliFastPath(command, process.argv.slice(3));
} else if (command === "__run-node-script") {
const { runNodeScript } = await import("./nodeScript.ts");
runNodeScript(process.argv.slice(3));
} else {
const { runCli } = await import("./binCli.ts");
runCli();
Expand Down
35 changes: 35 additions & 0 deletions apps/server/src/nodeScript.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// @effect-diagnostics nodeBuiltinImport:off - runs a CommonJS script from the filesystem.
import * as NodeFS from "node:fs";
import * as NodeOS from "node:os";
import * as NodePath from "node:path";

import { afterEach, describe, expect, it } from "vite-plus/test";

import { runNodeScript } from "./nodeScript.ts";

describe("runNodeScript", () => {
const originalArgv = process.argv;
afterEach(() => {
process.argv = originalArgv;
});

it("runs the script with the argv Node would give it", () => {
const directory = NodeFS.realpathSync(
NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-node-script-")),
);
const script = NodePath.join(directory, "server.cjs");
const output = NodePath.join(directory, "argv.json");
NodeFS.writeFileSync(
script,
`require("node:fs").writeFileSync(${JSON.stringify(output)}, JSON.stringify({ argv: process.argv.slice(1), filename: __filename }));`,
);

runNodeScript([script, "--stdio"]);

expect(JSON.parse(NodeFS.readFileSync(output, "utf8"))).toEqual({
argv: [script, "--stdio"],
filename: script,
});
NodeFS.rmSync(directory, { recursive: true, force: true });
});
});
23 changes: 23 additions & 0 deletions apps/server/src/nodeScript.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// @effect-diagnostics nodeBuiltinImport:off
// Runs before any Effect runtime is built, so it stays on Node built-ins.
import * as NodeModule from "node:module";
import * as NodeSea from "node:sea";

/**
* How to run a JavaScript file shipped with T3 (tsserver, Pyright) on T3's own runtime.
* The single-executable build always starts its embedded entry and ignores a script
* argument, so it goes through `t3 __run-node-script`, which loads the script itself.
*/
export function nodeScriptCommand(script: string, args: ReadonlyArray<string>) {
return {
command: process.execPath,
args: NodeSea.isSea() ? ["__run-node-script", script, ...args] : [script, ...args],
};
}

/** Entry for `t3 __run-node-script <script> [args]`: runs the script as if Node started it. */
export function runNodeScript([script, ...args]: ReadonlyArray<string>) {
if (!script) throw new Error("Usage: t3 __run-node-script <script> [args...]");
process.argv = [process.execPath, script, ...args];
NodeModule.createRequire(script)(script);
}
55 changes: 54 additions & 1 deletion apps/server/src/vcs/GitVcsDriverCore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,7 @@ it.effect("uses stable diagnostics for every parsed non-repository command", ()

assert.deepStrictEqual(commands, [
{ args: ["rev-parse", "--git-path", "index"], lcAll: "C" },
{ args: ["status", "--porcelain=2", "--branch"], lcAll: "C" },
{ args: ["status", "--porcelain=2", "-z", "--branch"], lcAll: "C" },
{ args: ["rev-parse", "--abbrev-ref", "HEAD"], lcAll: "C" },
{ args: ["rev-parse", "--git-common-dir"], lcAll: "C" },
]);
Expand All @@ -357,6 +357,59 @@ it.effect("invalidates origin remote cache when a driver mutation adds origin",
}).pipe(Effect.provide(TestLayer)),
);

it.effect("lists each working tree change once, including renames", () =>
Effect.gen(function* () {
const driver = yield* GitVcsDriver.GitVcsDriver;
const cwd = yield* makeTmpDir();
yield* initRepoWithCommit(cwd);
yield* writeTextFile(cwd, "apps/desktop/src/moved.ts", "one\ntwo\n");
yield* writeTextFile(cwd, "apps/desktop/src/edited.ts", "one\ntwo\nthree\nfour\n");
yield* git(cwd, ["add", "."]);
yield* git(cwd, ["commit", "-m", "desktop files"]);
// Shared path segments make git abbreviate renames as `{apps/desktop => packages/core}/...`.
const fileSystem = yield* FileSystem.FileSystem;
yield* fileSystem.makeDirectory(`${cwd}/packages/core/src`, { recursive: true });
yield* git(cwd, ["mv", "apps/desktop/src/moved.ts", "packages/core/src/moved.ts"]);
yield* git(cwd, ["mv", "apps/desktop/src/edited.ts", "packages/core/src/edited.ts"]);
yield* writeTextFile(cwd, "packages/core/src/edited.ts", "one\ntwo\nthree\nFOUR\n");
yield* writeTextFile(cwd, "README.md", "# changed\n");
yield* writeTextFile(cwd, "staged.txt", "staged\n");
yield* git(cwd, ["add", "staged.txt"]);
yield* writeTextFile(cwd, "untracked file.txt", "new\n");

const status = yield* driver.statusDetailsLocal(cwd);

assert.deepStrictEqual(status.workingTree.files, [
{ path: "packages/core/src/edited.ts", insertions: 1, deletions: 1 },
{ path: "packages/core/src/moved.ts", insertions: 0, deletions: 0 },
{ path: "README.md", insertions: 1, deletions: 1 },
{ path: "staged.txt", insertions: 1, deletions: 0 },
{ path: "untracked file.txt", insertions: 0, deletions: 0 },
]);
assert.equal(status.workingTree.insertions, 3);
assert.equal(status.workingTree.deletions, 2);
}).pipe(Effect.provide(TestLayer)),
);

it.effect("lists staged and unstaged changes before the first commit", () =>
Effect.gen(function* () {
const driver = yield* GitVcsDriver.GitVcsDriver;
const cwd = yield* makeTmpDir();
yield* driver.initRepo({ cwd });
yield* writeTextFile(cwd, "staged.txt", "one\n");
yield* git(cwd, ["add", "staged.txt"]);
yield* writeTextFile(cwd, "staged.txt", "one\ntwo\n");
yield* writeTextFile(cwd, "untracked.txt", "new\n");

const status = yield* driver.statusDetailsLocal(cwd);

assert.deepStrictEqual(status.workingTree.files, [
{ path: "staged.txt", insertions: 2, deletions: 0 },
{ path: "untracked.txt", insertions: 0, deletions: 0 },
]);
}).pipe(Effect.provide(TestLayer)),
);

it.effect("re-reads origin remote status after cache TTL expiry and bypassed invalidation", () =>
Effect.gen(function* () {
const driver = yield* GitVcsDriver.GitVcsDriver;
Expand Down
98 changes: 33 additions & 65 deletions apps/server/src/vcs/GitVcsDriverCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,30 +173,6 @@ function parseBranchAb(value: string): { ahead: number; behind: number } {
};
}

function parseNumstatEntries(
stdout: string,
): Array<{ path: string; insertions: number; deletions: number }> {
const entries: Array<{ path: string; insertions: number; deletions: number }> = [];
for (const line of stdout.split(/\r?\n/g)) {
if (line.trim().length === 0) continue;
const [addedRaw, deletedRaw, ...pathParts] = line.split("\t");
const rawPath =
pathParts.length > 1 ? (pathParts.at(-1) ?? "").trim() : pathParts.join("\t").trim();
if (rawPath.length === 0) continue;
const added = Number.parseInt(addedRaw ?? "0", 10);
const deleted = Number.parseInt(deletedRaw ?? "0", 10);
const renameArrowIndex = rawPath.indexOf(" => ");
const normalizedPath =
renameArrowIndex >= 0 ? rawPath.slice(renameArrowIndex + " => ".length).trim() : rawPath;
entries.push({
path: normalizedPath.length > 0 ? normalizedPath : rawPath,
insertions: Number.isFinite(added) ? added : 0,
deletions: Number.isFinite(deleted) ? deleted : 0,
});
}
return entries;
}

// -z preserves tabs/newlines in paths and gives renames two separate path fields.
function parseReviewNumstat(stdout: string): ReviewDiffFileStat[] {
const fields = stdout.split("\0");
Expand All @@ -217,26 +193,21 @@ function parseReviewNumstat(stdout: string): ReviewDiffFileStat[] {
return files;
}

function parsePorcelainPath(line: string): string | null {
if (line.startsWith("? ") || line.startsWith("! ")) {
const simple = line.slice(2).trim();
return simple.length > 0 ? simple : null;
}
// Space-separated fields before the path in `git status --porcelain=2 -z` records.
const PORCELAIN_V2_FIELDS_BEFORE_PATH: Record<string, number> = { "1": 8, "2": 9, u: 10 };

if (!(line.startsWith("1 ") || line.startsWith("2 ") || line.startsWith("u "))) {
return null;
function parsePorcelainPath(record: string): string | null {
if (record.startsWith("? ") || record.startsWith("! ")) {
return record.length > 2 ? record.slice(2) : null;
}

const tabIndex = line.indexOf("\t");
if (tabIndex >= 0) {
const fromTab = line.slice(tabIndex + 1);
const [filePath] = fromTab.split("\t");
return filePath?.trim().length ? filePath.trim() : null;
const fieldCount = PORCELAIN_V2_FIELDS_BEFORE_PATH[record.slice(0, 1)];
if (fieldCount === undefined || record[1] !== " ") return null;
let pathStart = 0;
for (let field = 0; field < fieldCount; field++) {
pathStart = record.indexOf(" ", pathStart) + 1;
if (pathStart === 0) return null;
}

const parts = line.trim().split(/\s+/g);
const filePath = parts.at(-1) ?? "";
return filePath.length > 0 ? filePath : null;
return pathStart < record.length ? record.slice(pathStart) : null;
}

function filterBranchesForListQuery(
Expand Down Expand Up @@ -1703,6 +1674,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
const statusArgs = [
"status",
"--porcelain=2",
"-z",
"--branch",
...(includeDivergence ? [] : ["--no-ahead-behind"]),
];
Expand Down Expand Up @@ -1781,54 +1753,43 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
Effect.catchTags({ GitCommandError: () => Effect.succeed(null) }),
);
const statusCacheKey = repositoryPaths?.gitCommonDir;
const [numstatStdout, defaultBranch, hasPrimaryRemote] = yield* Effect.all(
const [numstatEntries, defaultBranch, hasPrimaryRemote] = yield* Effect.all(
[
executeGitWithStableDiagnostics(
"GitVcsDriver.statusDetails.numstat",
cwd,
["diff", "HEAD", "--numstat", "--"],
["diff", "HEAD", "--numstat", "-z", "--"],
{ allowNonZeroExit: true },
).pipe(
Effect.flatMap((result) => {
if (result.exitCode === 0) return Effect.succeed(result.stdout);
if (result.exitCode === 0) return Effect.succeed(parseReviewNumstat(result.stdout));
if (isUnbornHeadStderr(result.stderr)) {
return Effect.map(
Effect.all([
runGitStdout("GitVcsDriver.statusDetails.numstat.unborn", cwd, [
"diff",
"--numstat",
"-z",
]),
runGitStdout("GitVcsDriver.statusDetails.numstat.unborn.staged", cwd, [
"diff",
"--cached",
"--numstat",
"-z",
]),
]),
([unstagedStdout, stagedStdout]) => {
const staged = parseNumstatEntries(stagedStdout);
const unstaged = parseNumstatEntries(unstagedStdout);
const map = new Map<string, { insertions: number; deletions: number }>();
for (const entry of [...staged, ...unstaged]) {
const existing = map.get(entry.path) ?? {
insertions: 0,
deletions: 0,
};
existing.insertions += entry.insertions;
existing.deletions += entry.deletions;
map.set(entry.path, existing);
}
return Array.from(map.entries())
.map(([p, s]) => `${s.insertions}\t${s.deletions}\t${p}`)
.join("\n");
},
([unstagedStdout, stagedStdout]) => [
...parseReviewNumstat(stagedStdout),
...parseReviewNumstat(unstagedStdout),
],
);
}
return Effect.fail(
new GitCommandError({
...gitCommandContext({
operation: "GitVcsDriver.statusDetails.numstat",
cwd,
args: ["diff", "HEAD", "--numstat", "--"],
args: ["diff", "HEAD", "--numstat", "-z", "--"],
}),
detail: "git diff HEAD --numstat failed.",
exitCode: result.exitCode,
Expand Down Expand Up @@ -1857,7 +1818,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
let hasWorkingTreeChanges = false;
const changedFilesWithoutNumstat = new Set<string>();

for (const line of statusStdout.split(/\r?\n/g)) {
// -z keeps paths unquoted; a rename record is followed by a record holding its old path.
const statusRecords = statusStdout.split("\0");
for (let index = 0; index < statusRecords.length; index++) {
const line = statusRecords[index]!;
if (line.startsWith("2 ")) index++;
if (line.startsWith("# branch.head ")) {
const value = line.slice("# branch.head ".length).trim();
refName = value.startsWith("(") ? null : value;
Expand Down Expand Up @@ -1903,10 +1868,13 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
: yield* computeAheadCountAgainstBase(cwd, refName).pipe(Effect.orElseSucceed(() => 0));
}

const numstatEntries = parseNumstatEntries(numstatStdout);
const fileStatMap = new Map<string, { insertions: number; deletions: number }>();
for (const entry of numstatEntries) {
fileStatMap.set(entry.path, { insertions: entry.insertions, deletions: entry.deletions });
const existing = fileStatMap.get(entry.path) ?? { insertions: 0, deletions: 0 };
fileStatMap.set(entry.path, {
insertions: existing.insertions + entry.additions,
deletions: existing.deletions + entry.deletions,
});
}

let insertions = 0;
Expand Down
7 changes: 5 additions & 2 deletions apps/server/src/workspace/LspLanguageBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,12 @@ import {
hoverMarkdown,
} from "./lspSchemas.ts";

// Build scripts and proc macros stay on, as in VS Code: without them derives such as
// serde's resolve to nothing and report false errors. The editor never sends didSave,
// so cargo check on save would never run.
const rustSettings = {
cargo: { buildScripts: { enable: false } },
procMacro: { enable: false },
cargo: { buildScripts: { enable: true } },
procMacro: { enable: true },
checkOnSave: false,
completion: { autoimport: { enable: false } },
};
Expand Down
21 changes: 11 additions & 10 deletions apps/server/src/workspace/TypeScriptSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import * as NodeChildProcess from "node:child_process";
import * as NodeModule from "node:module";
import * as Schema from "effect/Schema";
import { nodeScriptCommand } from "../nodeScript.ts";

const Response = Schema.Struct({
type: Schema.String,
Expand Down Expand Up @@ -35,16 +36,16 @@ export class TypeScriptSession {
const tsserver = NodeModule.createRequire(import.meta.url).resolve(
"typescript-tsserver/lib/tsserver.js",
);
this.child = NodeChildProcess.spawn(
process.execPath,
[tsserver, "--disableAutomaticTypingAcquisition", "--noGetErrOnBackgroundUpdate"],
{
cwd,
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
env: { ...process.env, ELECTRON_RUN_AS_NODE: "1", NODE_OPTIONS: "", TSS_LOG: "" },
},
);
const launch = nodeScriptCommand(tsserver, [
"--disableAutomaticTypingAcquisition",
"--noGetErrOnBackgroundUpdate",
]);
this.child = NodeChildProcess.spawn(launch.command, launch.args, {
cwd,
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
env: { ...process.env, ELECTRON_RUN_AS_NODE: "1", NODE_OPTIONS: "", TSS_LOG: "" },
});
this.child.stdout.on("data", (chunk: Buffer) => this.receive(chunk));
this.child.stderr.resume();
this.child.on("error", (error) => this.dispose(error));
Expand Down
Loading