Skip to content
Closed
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
26 changes: 26 additions & 0 deletions apps/server/src/vcs/GitVcsDriverCore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,32 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
}),
);

it.effect("reports working tree file statuses", () =>
Effect.gen(function* () {
const cwd = yield* makeTmpDir();
yield* initRepoWithCommit(cwd);
yield* writeTextFile(cwd, "tracked.txt", "tracked\n");
yield* git(cwd, ["add", "tracked.txt"]);
yield* git(cwd, ["commit", "-m", "tracked file"]);

yield* writeTextFile(cwd, "added.ts", "export const added = true;\n");
yield* git(cwd, ["add", "added.ts"]);
yield* git(cwd, ["rm", "README.md"]);
yield* git(cwd, ["mv", "tracked.txt", "renamed.txt"]);
yield* writeTextFile(cwd, "untracked.ts", "export const untracked = true;\n");

const status = yield* (yield* GitVcsDriver.GitVcsDriver).statusDetails(cwd);
const statusByPath = new Map(
status.workingTree.files.map((file) => [file.path, file.status]),
);

assert.equal(statusByPath.get("added.ts"), "added");
assert.equal(statusByPath.get("README.md"), "deleted");
assert.equal(statusByPath.get("renamed.txt"), "renamed");
assert.equal(statusByPath.get("untracked.ts"), "untracked");
}),
);

it.effect("reports default-branch delta separately from upstream delta", () =>
Effect.gen(function* () {
const cwd = yield* makeTmpDir();
Expand Down
55 changes: 49 additions & 6 deletions apps/server/src/vcs/GitVcsDriverCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
type ReviewDiffPreviewInput,
type ReviewDiffPreviewSource,
type VcsRef,
type VcsWorkingTreeFileStatus,
} from "@t3tools/contracts";
import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git";
import { compactTraceAttributes } from "@t3tools/shared/observability";
Expand Down Expand Up @@ -146,6 +147,13 @@ function parsePorcelainPath(line: string): string | null {
return null;
}

if (line.startsWith("2 ")) {
const beforeOriginalPath = line.split("\t", 1)[0] ?? "";
const parts = beforeOriginalPath.trim().split(/\s+/g);
const filePath = parts.at(-1) ?? "";
return filePath.length > 0 ? filePath : null;
}

const tabIndex = line.indexOf("\t");
if (tabIndex >= 0) {
const fromTab = line.slice(tabIndex + 1);
Expand All @@ -158,6 +166,36 @@ function parsePorcelainPath(line: string): string | null {
return filePath.length > 0 ? filePath : null;
}

function parsePorcelainStatusCode(code: string): VcsWorkingTreeFileStatus {
if (code.includes("R")) return "renamed";
if (code.includes("D")) return "deleted";
if (code.includes("A")) return "added";
return "modified";
}

function parsePorcelainStatus(
line: string,
): { readonly path: string; readonly status: VcsWorkingTreeFileStatus } | null {
if (line.startsWith("? ")) {
const path = parsePorcelainPath(line);
return path ? { path, status: "untracked" } : null;
}

if (line.startsWith("! ")) {
const path = parsePorcelainPath(line);
return path ? { path, status: "ignored" } : null;
}

if (!(line.startsWith("1 ") || line.startsWith("2 ") || line.startsWith("u "))) {
return null;
}

const parts = line.trim().split(/\s+/g);
const code = parts.at(1) ?? "";
const path = parsePorcelainPath(line);
return path ? { path, status: parsePorcelainStatusCode(code) } : null;
}

function parseBranchLine(line: string): { name: string; current: boolean } | null {
const trimmed = line.trim();
if (trimmed.length === 0) return null;
Expand Down Expand Up @@ -1329,7 +1367,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
let behindCount = 0;
let aheadOfDefaultCount = 0;
let hasWorkingTreeChanges = false;
const changedFilesWithoutNumstat = new Set<string>();
const changedFileStatuses = new Map<string, VcsWorkingTreeFileStatus>();

for (const line of statusStdout.split(/\r?\n/g)) {
if (line.startsWith("# branch.head ")) {
Expand All @@ -1351,8 +1389,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
}
if (line.trim().length > 0 && !line.startsWith("#")) {
hasWorkingTreeChanges = true;
const pathValue = parsePorcelainPath(line);
if (pathValue) changedFilesWithoutNumstat.add(pathValue);
const status = parsePorcelainStatus(line);
if (status) changedFileStatuses.set(status.path, status.status);
}
}

Expand Down Expand Up @@ -1393,13 +1431,18 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
.map(([filePath, stat]) => {
insertions += stat.insertions;
deletions += stat.deletions;
return { path: filePath, insertions: stat.insertions, deletions: stat.deletions };
return {
path: filePath,
insertions: stat.insertions,
deletions: stat.deletions,
status: changedFileStatuses.get(filePath) ?? "modified",
};
})
.toSorted((a, b) => a.path.localeCompare(b.path));

for (const filePath of changedFilesWithoutNumstat) {
for (const [filePath, status] of changedFileStatuses) {
if (fileStatMap.has(filePath)) continue;
files.push({ path: filePath, insertions: 0, deletions: 0 });
files.push({ path: filePath, insertions: 0, deletions: 0, status });
}
files.sort((a, b) => a.path.localeCompare(b.path));

Expand Down
27 changes: 27 additions & 0 deletions apps/server/src/workspace/Layers/WorkspaceEntries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@ const searchWorkspaceEntries = (input: { cwd: string; query: string; limit: numb
return yield* workspaceEntries.search(input);
});

const listWorkspaceEntries = (input: { cwd: string }) =>
Effect.gen(function* () {
const workspaceEntries = yield* WorkspaceEntries;
return yield* workspaceEntries.listEntries(input);
});

const appendSeparator = (input: string) =>
Effect.map(HostProcessPlatform, (platform) =>
input.endsWith("/") || input.endsWith("\\")
Expand Down Expand Up @@ -316,6 +322,27 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => {
);
});

describe("listEntries", () => {
it.effect("includes gitignored paths for the project tree", () =>
Effect.gen(function* () {
const cwd = yield* makeTempDir({ prefix: "t3code-workspace-list-gitignore-", git: true });
yield* writeTextFile(cwd, ".gitignore", "ignored.txt\nignored-dir/\n");
yield* writeTextFile(cwd, "src/keep.ts", "export {};");
yield* writeTextFile(cwd, "ignored.txt", "ignore me");
yield* writeTextFile(cwd, "ignored-dir/file.ts", "export {};");

const result = yield* listWorkspaceEntries({ cwd });
const paths = result.entries.map((entry) => entry.path);

expect(paths).toContain("src");
expect(paths).toContain("src/keep.ts");
expect(paths).toContain("ignored.txt");
expect(paths).toContain("ignored-dir");
expect(paths).toContain("ignored-dir/file.ts");
}),
);
});

describe("browse", () => {
it.effect("returns matching directories and excludes files", () =>
Effect.gen(function* () {
Expand Down
34 changes: 32 additions & 2 deletions apps/server/src/workspace/Layers/WorkspaceEntries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,8 +306,12 @@ export const makeWorkspaceEntries = Effect.gen(function* () {

const buildWorkspaceIndexFromFilesystem = Effect.fn(
"WorkspaceEntries.buildWorkspaceIndexFromFilesystem",
)(function* (cwd: string): Effect.fn.Return<WorkspaceIndex, WorkspaceEntriesError> {
const shouldFilterWithGitIgnore = yield* isInsideVcsWorkTree(cwd);
)(function* (
cwd: string,
options?: { readonly filterGitIgnored?: boolean },
): Effect.fn.Return<WorkspaceIndex, WorkspaceEntriesError> {
const shouldFilterWithGitIgnore =
options?.filterGitIgnored === false ? false : yield* isInsideVcsWorkTree(cwd);

let pendingDirectories: string[] = [""];
const entries: SearchableWorkspaceEntry[] = [];
Expand Down Expand Up @@ -413,6 +417,15 @@ export const makeWorkspaceEntries = Effect.gen(function* () {
Exit.isSuccess(exit) ? Duration.millis(WORKSPACE_CACHE_TTL_MS) : Duration.zero,
},
);
const workspaceListEntriesCache = yield* Cache.makeWith<
string,
WorkspaceIndex,
WorkspaceEntriesError
>((cwd) => buildWorkspaceIndexFromFilesystem(cwd, { filterGitIgnored: false }), {
capacity: WORKSPACE_CACHE_MAX_KEYS,
timeToLive: (exit) =>
Exit.isSuccess(exit) ? Duration.millis(WORKSPACE_CACHE_TTL_MS) : Duration.zero,
});

const normalizeWorkspaceRoot = Effect.fn("WorkspaceEntries.normalizeWorkspaceRoot")(function* (
cwd: string,
Expand All @@ -436,8 +449,10 @@ export const makeWorkspaceEntries = Effect.gen(function* () {
Effect.orElseSucceed(() => cwd),
);
yield* Cache.invalidate(workspaceIndexCache, cwd);
yield* Cache.invalidate(workspaceListEntriesCache, cwd);
if (normalizedCwd !== cwd) {
yield* Cache.invalidate(workspaceIndexCache, normalizedCwd);
yield* Cache.invalidate(workspaceListEntriesCache, normalizedCwd);
}
},
);
Expand Down Expand Up @@ -530,9 +545,24 @@ export const makeWorkspaceEntries = Effect.gen(function* () {
},
);

const listEntries: WorkspaceEntriesShape["listEntries"] = Effect.fn(
"WorkspaceEntries.listEntries",
)(function* (input) {
const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd);
return yield* Cache.get(workspaceListEntriesCache, normalizedCwd).pipe(
Effect.map((index) => ({
entries: index.entries.map(
({ normalizedName: _normalizedName, normalizedPath: _normalizedPath, ...entry }) => entry,
),
truncated: index.truncated,
})),
);
});

return {
browse,
invalidate,
listEntries,
search,
} satisfies WorkspaceEntriesShape;
});
Expand Down
61 changes: 61 additions & 0 deletions apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,68 @@ const writeTextFile = Effect.fn("writeTextFile")(function* (
yield* fileSystem.writeFileString(absolutePath, contents).pipe(Effect.orDie);
});

const writeDirectorySymlink = Effect.fn("writeDirectorySymlink")(function* (
cwd: string,
relativePath: string,
targetPath: string,
) {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const absolutePath = path.join(cwd, relativePath);
yield* fileSystem
.makeDirectory(path.dirname(absolutePath), { recursive: true })
.pipe(Effect.orDie);
yield* fileSystem.symlink(targetPath, absolutePath).pipe(Effect.orDie);
});

it.layer(TestLayer)("WorkspaceFileSystemLive", (it) => {
describe("readFile", () => {
it.effect("reads files relative to the workspace root", () =>
Effect.gen(function* () {
const workspaceFileSystem = yield* WorkspaceFileSystem;
const cwd = yield* makeTempDir;
yield* writeTextFile(cwd, "plans/effect-rpc.md", "# Plan\n");

const result = yield* workspaceFileSystem.readFile({
cwd,
relativePath: "plans/effect-rpc.md",
});

expect(result).toEqual({
relativePath: "plans/effect-rpc.md",
contents: "# Plan\n",
});
}),
);

it.effect("rejects symlinked paths that resolve outside the workspace root", () =>
Effect.gen(function* () {
const workspaceFileSystem = yield* WorkspaceFileSystem;
const cwd = yield* makeTempDir;
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;

const externalDir = `${cwd}-outside`;
yield* fileSystem.makeDirectory(externalDir, { recursive: true }).pipe(Effect.orDie);
yield* fileSystem
.writeFileString(path.join(externalDir, "secret.txt"), "secret\n")
.pipe(Effect.orDie);
yield* writeDirectorySymlink(cwd, "linked", externalDir);

const error = yield* workspaceFileSystem
.readFile({
cwd,
relativePath: "linked/secret.txt",
})
.pipe(Effect.flip);

expect(error.message).toContain(
"Workspace file path must be relative to the project root: linked/secret.txt",
);
}),
);
});

describe("writeFile", () => {
it.effect("writes files relative to the workspace root", () =>
Effect.gen(function* () {
Expand Down
Loading
Loading