Skip to content
Open
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: 52 additions & 10 deletions src/products/tree/engine/workspace.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import {
existsSync,
lstatSync,
readdirSync,
readFileSync,
type Dirent,
} from "node:fs";
import { join, relative, resolve } from "node:path";
import { Repo } from "#products/tree/engine/repo.js";
import { TREE_SUBMODULES_DIR } from "#products/tree/engine/runtime/asset-loader.js";
Expand All @@ -22,11 +28,42 @@ const IGNORED_DIRS = new Set([
"node_modules",
".next",
".turbo",
"target",
"vendor",
"__pycache__",
".gradle",
".idea",
".vscode",
"coverage",
"out",
".cache",
".pytest_cache",
]);

function isDirectory(path: string): boolean {
const TREE_SUBMODULES_PREFIX = `${TREE_SUBMODULES_DIR.split(/[\\/]/).join("/")}/`;

function isTraversableDirectory(current: string, entry: Dirent): boolean {
if (entry.isSymbolicLink()) {
return false;
}
if (entry.isDirectory()) {
return true;
}
if (
entry.isFile() ||
entry.isBlockDevice() ||
entry.isCharacterDevice() ||
entry.isFIFO() ||
entry.isSocket()
) {
return false;
}

// Some filesystems report an unknown dirent type; fall back to lstat so
// nested repos on network/FUSE mounts are still discovered.
try {
return statSync(path).isDirectory();
const stats = lstatSync(join(current, entry.name));
return !stats.isSymbolicLink() && stats.isDirectory();
} catch {
return false;
}
Expand All @@ -39,7 +76,7 @@ function parseGitmodules(root: string): string[] {
.map((match) => match[1]?.trim())
.filter(
(value): value is string =>
Boolean(value) && !value.startsWith(`${TREE_SUBMODULES_DIR}/`),
Boolean(value) && !value.startsWith(TREE_SUBMODULES_PREFIX),
);
} catch {
return [];
Expand All @@ -51,21 +88,23 @@ function discoverNestedRepos(
current: string,
results: Map<string, WorkspaceRepoCandidate>,
): void {
let entries: string[] = [];
let entries: Dirent[] = [];
try {
entries = readdirSync(current);
entries = readdirSync(current, { withFileTypes: true });
} catch {
return;
}

for (const entry of entries) {
if (IGNORED_DIRS.has(entry)) {
if (IGNORED_DIRS.has(entry.name)) {
continue;
}
const child = join(current, entry);
if (!isDirectory(child)) {
// Skip symlinks to avoid recursion cycles (repo pointing into itself,
// shared toolchain dirs, etc.).
if (!isTraversableDirectory(current, entry)) {
continue;
}
const child = join(current, entry.name);

const repo = new Repo(child);
if (repo.isGitRepo() && repo.root !== root && repo.root === resolve(child)) {
Expand All @@ -92,6 +131,9 @@ export function discoverWorkspaceRepos(root: string): WorkspaceRepoCandidate[] {
const submoduleRoot = resolve(root, submodulePath);
const repo = new Repo(submoduleRoot);
if (!repo.isGitRepo()) {
console.warn(
`warning: submodule "${submodulePath}" is declared in .gitmodules but is not initialized; skipping. Run \`git submodule update --init\` to include it.`,
);
continue;
}
results.set(submodulePath, {
Expand All @@ -107,6 +149,6 @@ export function discoverWorkspaceRepos(root: string): WorkspaceRepoCandidate[] {
}

return [...results.values()].sort((left, right) =>
left.relativePath.localeCompare(right.relativePath)
left.relativePath.localeCompare(right.relativePath, "en")
);
}
81 changes: 81 additions & 0 deletions tests/tree/workspace.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { makeSourceRepo, useTmpDir } from "../helpers.js";

const mockState = vi.hoisted(() => ({
root: null as string | null,
entries: null as import("node:fs").Dirent[] | null,
}));

vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();

return {
...actual,
readdirSync: ((path: unknown, options?: unknown) => {
if (
path === mockState.root &&
typeof options === "object" &&
options !== null &&
"withFileTypes" in options &&
options.withFileTypes === true &&
mockState.entries !== null
) {
return mockState.entries;
}
return actual.readdirSync(
path as Parameters<typeof actual.readdirSync>[0],
options as never,
);
}) as typeof actual.readdirSync,
};
});

describe("discoverWorkspaceRepos", () => {
afterEach(() => {
mockState.root = null;
mockState.entries = null;
vi.resetModules();
});

it("falls back to lstat when dirent type is unknown", async () => {
const tmp = useTmpDir();
makeSourceRepo(tmp.path);
const nestedRoot = join(tmp.path, "nested");
makeSourceRepo(nestedRoot);

const actualFs = await vi.importActual<typeof import("node:fs")>("node:fs");
const realReaddirSync = actualFs.readdirSync.bind(actualFs);
const rootEntries = realReaddirSync(tmp.path, { withFileTypes: true });

mockState.root = tmp.path;
mockState.entries = rootEntries.map((entry) =>
entry.name === "nested"
? ({
name: entry.name,
isBlockDevice: () => false,
isCharacterDevice: () => false,
isDirectory: () => false,
isFIFO: () => false,
isFile: () => false,
isSocket: () => false,
isSymbolicLink: () => false,
} as import("node:fs").Dirent)
: entry
);
vi.resetModules();

const { discoverWorkspaceRepos } = await import(
"#products/tree/engine/workspace.js"
);

expect(discoverWorkspaceRepos(tmp.path)).toEqual([
{
kind: "nested-git-repo",
name: "nested",
relativePath: "nested",
root: nestedRoot,
},
]);
});
});