diff --git a/packages/turbo-repository/__tests__/affected-packages.test.ts b/packages/turbo-repository/__tests__/affected-packages.test.ts new file mode 100644 index 0000000000000..95cd9e39f9202 --- /dev/null +++ b/packages/turbo-repository/__tests__/affected-packages.test.ts @@ -0,0 +1,61 @@ +import * as path from "node:path"; +import { Workspace, Package, PackageManager } from "../js/dist/index.js"; + +type PackageReduced = Pick; + +interface AffectedPackagesTestParams { + files: string[]; + expected: PackageReduced[]; + description: string; +} + +describe("affectedPackages", () => { + const tests: AffectedPackagesTestParams[] = [ + { + description: "app change", + files: ["apps/app/file.txt"], + expected: [{ name: "app-a", relativePath: "apps/app" }], + }, + { + description: "lib change", + files: ["packages/ui/a.txt"], + expected: [{ name: "ui", relativePath: "packages/ui" }], + }, + { + description: "global change", + files: ["package.json"], + expected: [ + { name: "app-a", relativePath: "apps/app" }, + { name: "ui", relativePath: "packages/ui" }, + ], + }, + { + description: + "global change should be irrelevant but still triggers all packages", + files: ["README.md"], + expected: [ + { name: "app-a", relativePath: "apps/app" }, + { name: "ui", relativePath: "packages/ui" }, + ], + }, + ]; + + test.each(tests)( + "$description", + async (testParams: AffectedPackagesTestParams) => { + const { files, expected } = testParams; + const dir = path.resolve(__dirname, "./fixtures/monorepo"); + const workspace = await Workspace.find(dir); + const reduced: PackageReduced[] = ( + await workspace.affectedPackages(files) + ).map((pkg) => { + return { + name: pkg.name, + relativePath: pkg.relativePath, + }; + }); + + expect(reduced).toEqual(expected); + } + ); +}); diff --git a/packages/turbo-repository/__tests__/find-packages.test.ts b/packages/turbo-repository/__tests__/find-packages.test.ts new file mode 100644 index 0000000000000..3ac9222702987 --- /dev/null +++ b/packages/turbo-repository/__tests__/find-packages.test.ts @@ -0,0 +1,27 @@ +import * as path from "node:path"; +import { Workspace, Package } from "../js/dist/index.js"; + +describe("findPackages", () => { + it("enumerates packages", async () => { + const workspace = await Workspace.find("./fixtures/monorepo"); + const packages: Package[] = await workspace.findPackages(); + expect(packages.length).not.toBe(0); + }); + + it("returns a package graph", async () => { + const dir = path.resolve(__dirname, "./fixtures/monorepo"); + const workspace = await Workspace.find(dir); + const packages = await workspace.findPackagesWithGraph(); + + expect(Object.keys(packages).length).toBe(2); + + const pkg1 = packages["apps/app"]; + const pkg2 = packages["packages/ui"]; + + expect(pkg1.dependencies).toEqual(["packages/ui"]); + expect(pkg1.dependents).toEqual([]); + + expect(pkg2.dependencies).toEqual([]); + expect(pkg2.dependents).toEqual(["apps/app"]); + }); +}); diff --git a/packages/turbo-repository/__tests__/find.test.ts b/packages/turbo-repository/__tests__/find.test.ts deleted file mode 100644 index ffbe933b5fbc6..0000000000000 --- a/packages/turbo-repository/__tests__/find.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import * as path from "node:path"; -import { Workspace, Package, PackageManager } from "../js/dist/index.js"; - -type PackageReduced = Pick; - -interface AffectedPackagesTestParams { - files: string[]; - expected: PackageReduced[]; - description: string; -} - -describe("Workspace", () => { - it("finds a workspace", async () => { - const workspace = await Workspace.find(); - const expectedRoot = path.resolve(__dirname, "../../.."); - expect(workspace.absolutePath).toBe(expectedRoot); - }); - - it("enumerates packages", async () => { - const workspace = await Workspace.find(); - const packages: Package[] = await workspace.findPackages(); - expect(packages.length).not.toBe(0); - }); - - it("finds a package manager", async () => { - const workspace = await Workspace.find(); - const packageManager: PackageManager = workspace.packageManager; - expect(packageManager.name).toBe("pnpm"); - }); - - test("returns a package graph", async () => { - const dir = path.resolve(__dirname, "./fixtures/monorepo"); - const workspace = await Workspace.find(dir); - const graph = await workspace.findPackagesAndDependents(); - expect(graph).toEqual({ - "apps/app": [], - "packages/ui": ["apps/app"], - }); - }); - - describe("affectedPackages", () => { - const tests: AffectedPackagesTestParams[] = [ - { - description: "app change", - files: ["apps/app/file.txt"], - expected: [{ name: "app-a", relativePath: "apps/app" }], - }, - { - description: "lib change", - files: ["packages/ui/a.txt"], - expected: [{ name: "ui", relativePath: "packages/ui" }], - }, - { - description: "global change", - files: ["package.json"], - expected: [ - { name: "app-a", relativePath: "apps/app" }, - { name: "ui", relativePath: "packages/ui" }, - ], - }, - { - description: - "global change should be irrelevant but still triggers all packages", - files: ["README.md"], - expected: [ - { name: "app-a", relativePath: "apps/app" }, - { name: "ui", relativePath: "packages/ui" }, - ], - }, - ]; - - test.each(tests)( - "$description", - async (testParams: AffectedPackagesTestParams) => { - const { files, expected } = testParams; - const dir = path.resolve(__dirname, "./fixtures/monorepo"); - const workspace = await Workspace.find(dir); - const reduced: PackageReduced[] = ( - await workspace.affectedPackages(files) - ).map((pkg) => { - return { - name: pkg.name, - relativePath: pkg.relativePath, - }; - }); - - expect(reduced).toEqual(expected); - } - ); - }); -}); diff --git a/packages/turbo-repository/__tests__/workspace.test.ts b/packages/turbo-repository/__tests__/workspace.test.ts new file mode 100644 index 0000000000000..b55d8502b841f --- /dev/null +++ b/packages/turbo-repository/__tests__/workspace.test.ts @@ -0,0 +1,16 @@ +import * as path from "node:path"; +import { Workspace, PackageManager } from "../js/dist/index.js"; + +describe("Workspace", () => { + it("finds a workspace", async () => { + const workspace = await Workspace.find(); + const expectedRoot = path.resolve(__dirname, "../../.."); + expect(workspace.absolutePath).toBe(expectedRoot); + }); + + it("finds a package manager", async () => { + const workspace = await Workspace.find(); + const packageManager: PackageManager = workspace.packageManager; + expect(packageManager.name).toBe("pnpm"); + }); +}); diff --git a/packages/turbo-repository/js/index.d.ts b/packages/turbo-repository/js/index.d.ts index f5015ab51a3e3..92485efe32f8e 100644 --- a/packages/turbo-repository/js/index.d.ts +++ b/packages/turbo-repository/js/index.d.ts @@ -10,6 +10,10 @@ export class Package { /** The relative path from the workspace root to the package root. */ readonly relativePath: string; } +export class PackageDetails { + readonly dependencies: Array; + readonly dependents: Array; +} export class PackageManager { /** The package manager name in lower case. */ readonly name: string; @@ -29,10 +33,16 @@ export class Workspace { /** Finds and returns packages within the workspace. */ findPackages(): Promise>; /** - * Finds and returns a map of packages within the workspace and its - * dependents (i.e. the packages that depend on each of those packages). + * Returns a map of packages within the workspace, its dependencies and + * dependents. The response looks like this: + * { + * "package-path": { + * "dependents": ["dependent1_path", "dependent2_path"], + * "dependencies": ["dependency1_path", "dependency2_path"] + * } + * } */ - findPackagesAndDependents(): Promise>>; + findPackagesWithGraph(): Promise; /** * Given a set of "changed" files, returns a set of packages that are * "affected" by the changes. The `files` argument is expected to be a list diff --git a/packages/turbo-repository/rust/src/lib.rs b/packages/turbo-repository/rust/src/lib.rs index bf33b89b7d53c..a8c267a4ac4b7 100644 --- a/packages/turbo-repository/rust/src/lib.rs +++ b/packages/turbo-repository/rust/src/lib.rs @@ -25,9 +25,20 @@ pub struct Package { pub relative_path: String, } -#[derive(Clone)] +type RelativePath = String; + #[napi] +#[derive(Debug)] +pub struct PackageDetails { + #[napi(readonly)] + pub dependencies: Vec, + #[napi(readonly)] + pub dependents: Vec, +} +type SerializablePackages = HashMap; +#[derive(Clone)] +#[napi] pub struct PackageManager { /// The package manager name in lower case. #[napi(readonly)] @@ -73,13 +84,36 @@ impl Package { workspace_path: &AbsoluteSystemPath, ) -> Vec { let node = PackageNode::Workspace(PackageName::Other(self.name.clone())); - let ancestors = match graph.immediate_ancestors(&node) { - Some(ancestors) => ancestors, + let pkgs = match graph.immediate_ancestors(&node) { + Some(pkgs) => pkgs, + None => return vec![], + }; + + pkgs.iter() + .filter_map(|node| { + let info = graph.package_info(node.as_package_name())?; + // If we don't get a package name back, we'll just skip it. + let name = info.package_name()?; + let anchored_package_path = info.package_path(); + let package_path = workspace_path.resolve(anchored_package_path); + Some(Package::new(name, workspace_path, &package_path)) + }) + .collect() + } + + fn dependencies( + &self, + graph: &PackageGraph, + workspace_path: &AbsoluteSystemPath, + ) -> Vec { + let node = PackageNode::Workspace(PackageName::Other(self.name.clone())); + let pkgs = match graph.immediate_dependencies(&node) { + Some(pkgs) => pkgs, None => return vec![], }; - ancestors - .iter() + pkgs.iter() + .filter(|node| !matches!(node, PackageNode::Root)) .filter_map(|node| { let info = graph.package_info(node.as_package_name())?; // If we don't get a package name back, we'll just skip it. @@ -107,12 +141,16 @@ impl Workspace { self.packages_internal().await.map_err(|e| e.into()) } - /// Finds and returns a map of packages within the workspace and its - /// dependents (i.e. the packages that depend on each of those packages). + /// Returns a map of packages within the workspace, its dependencies and + /// dependents. The response looks like this: + /// { + /// "package-path": { + /// "dependents": ["dependent1_path", "dependent2_path"], + /// "dependencies": ["dependency1_path", "dependency2_path"] + /// } + /// } #[napi] - pub async fn find_packages_and_dependents( - &self, - ) -> Result>, Error> { + pub async fn find_packages_with_graph(&self) -> Result { let packages = self.find_packages().await?; let workspace_path = match AbsoluteSystemPath::new(self.absolute_path.as_str()) { @@ -120,16 +158,23 @@ impl Workspace { Err(e) => return Err(Error::from_reason(e.to_string())), }; - let map: HashMap> = packages + let map: HashMap = packages .into_iter() .map(|package| { - let deps = package.dependents(&self.graph, workspace_path); - let dep_names = deps - .into_iter() - .map(|p| p.relative_path) - .collect::>(); - - (package.relative_path, dep_names) + let details = PackageDetails { + dependencies: package + .dependencies(&self.graph, workspace_path) + .into_iter() + .map(|p| p.relative_path) + .collect(), + dependents: package + .dependents(&self.graph, workspace_path) + .into_iter() + .map(|p| p.relative_path) + .collect(), + }; + + (package.relative_path, details) }) .collect();