Skip to content

Commit c48bc8a

Browse files
feat(@turbo/repository): add affectedPackages API (#7326)
This API takes a list of files (strings anchored to the root of the workspace) and returns a set of packages that were affected by that change. Importantly, this does _not_ include the whole graph of packages affected by these files changes, so it's a pretty simple mapping of files to the packages they belong to. If a known global file changes, all packages are returned. Co-authored-by: Chris Olszewski <[email protected]>
1 parent 1953cda commit c48bc8a

File tree

3 files changed

+125
-3
lines changed

3 files changed

+125
-3
lines changed

packages/turbo-repository/__tests__/find.test.ts

+55
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
import * as path from "node:path";
22
import { Workspace, Package, PackageManager } from "../js/dist/index.js";
33

4+
type PackageReduced = Pick<Package, "name" | "relativePath">;
5+
6+
interface AffectedPackagesTestParams {
7+
files: string[];
8+
expected: PackageReduced[];
9+
description: string;
10+
}
11+
412
describe("Workspace", () => {
513
it("finds a workspace", async () => {
614
const workspace = await Workspace.find();
@@ -29,4 +37,51 @@ describe("Workspace", () => {
2937
"packages/ui": ["apps/app"],
3038
});
3139
});
40+
41+
describe("affectedPackages", () => {
42+
const tests: AffectedPackagesTestParams[] = [
43+
{
44+
description: "app change",
45+
files: ["apps/app/file.txt"],
46+
expected: [{ name: "app-a", relativePath: "apps/app" }],
47+
},
48+
{
49+
description: "lib change",
50+
files: ["packages/ui/a.txt"],
51+
expected: [{ name: "ui", relativePath: "packages/ui" }],
52+
},
53+
{
54+
description: "global change",
55+
files: ["package.json"],
56+
expected: [
57+
{ name: "app-a", relativePath: "apps/app" },
58+
{ name: "ui", relativePath: "packages/ui" },
59+
],
60+
},
61+
{
62+
description: "global change that can be ignored",
63+
files: ["README.md"],
64+
expected: [],
65+
},
66+
];
67+
68+
test.each(tests)(
69+
"$description",
70+
async (testParams: AffectedPackagesTestParams) => {
71+
const { files, expected } = testParams;
72+
const dir = path.resolve(__dirname, "./fixtures/monorepo");
73+
const workspace = await Workspace.find(dir);
74+
const reduced: PackageReduced[] = (
75+
await workspace.affectedPackages(files)
76+
).map((pkg) => {
77+
return {
78+
name: pkg.name,
79+
relativePath: pkg.relativePath,
80+
};
81+
});
82+
83+
expect(reduced).toEqual(expected);
84+
}
85+
);
86+
});
3287
});

packages/turbo-repository/js/index.d.ts

+7
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,11 @@ export class Workspace {
3333
* dependents (i.e. the packages that depend on each of those packages).
3434
*/
3535
findPackagesAndDependents(): Promise<Record<string, Array<string>>>;
36+
/**
37+
* Given a set of "changed" files, returns a set of packages that are
38+
* "affected" by the changes. The `files` argument is expected to be a list
39+
* of strings relative to the monorepo root and use the current system's
40+
* path separator.
41+
*/
42+
affectedPackages(files: Array<string>): Promise<Array<Package>>;
3643
}

packages/turbo-repository/rust/src/lib.rs

+63-3
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
1-
use std::{collections::HashMap, hash::Hash};
1+
use std::{
2+
collections::{HashMap, HashSet},
3+
hash::Hash,
4+
};
25

36
use napi::Error;
47
use napi_derive::napi;
5-
use turbopath::AbsoluteSystemPath;
8+
use turbopath::{AbsoluteSystemPath, AnchoredSystemPathBuf};
69
use turborepo_repository::{
10+
change_mapper::{ChangeMapper, PackageChanges},
711
inference::RepoState as WorkspaceState,
8-
package_graph::{PackageGraph, PackageNode, WorkspaceName},
12+
package_graph::{PackageGraph, PackageNode, WorkspaceName, WorkspacePackage, ROOT_PKG_NAME},
913
};
1014
mod internal;
1115

@@ -131,4 +135,60 @@ impl Workspace {
131135

132136
Ok(map)
133137
}
138+
139+
/// Given a set of "changed" files, returns a set of packages that are
140+
/// "affected" by the changes. The `files` argument is expected to be a list
141+
/// of strings relative to the monorepo root and use the current system's
142+
/// path separator.
143+
#[napi]
144+
pub async fn affected_packages(&self, files: Vec<String>) -> Result<Vec<Package>, Error> {
145+
let workspace_root = match AbsoluteSystemPath::new(&self.absolute_path) {
146+
Ok(path) => path,
147+
Err(e) => return Err(Error::from_reason(e.to_string())),
148+
};
149+
150+
let hash_set_of_paths: HashSet<AnchoredSystemPathBuf> = files
151+
.into_iter()
152+
.filter_map(|path| {
153+
let path_components = path.split(std::path::MAIN_SEPARATOR).collect::<Vec<&str>>();
154+
let absolute_path = workspace_root.join_components(&path_components);
155+
workspace_root.anchor(&absolute_path).ok()
156+
})
157+
.collect();
158+
159+
// Create a ChangeMapper with no custom global deps or ignore patterns
160+
let mapper = ChangeMapper::new(&self.graph, vec![], vec![]);
161+
let package_changes = match mapper.changed_packages(hash_set_of_paths, None) {
162+
Ok(changes) => changes,
163+
Err(e) => return Err(Error::from_reason(e.to_string())),
164+
};
165+
166+
let packages = match package_changes {
167+
PackageChanges::All => self
168+
.graph
169+
.workspaces()
170+
.map(|(name, info)| WorkspacePackage {
171+
name: name.to_owned(),
172+
path: info.package_path().to_owned(),
173+
})
174+
.collect::<Vec<WorkspacePackage>>(),
175+
PackageChanges::Some(packages) => packages.into_iter().collect(),
176+
};
177+
178+
let mut serializable_packages: Vec<Package> = packages
179+
.into_iter()
180+
.filter(|p| match &p.name {
181+
WorkspaceName::Root => false,
182+
WorkspaceName::Other(name) => name != ROOT_PKG_NAME,
183+
})
184+
.map(|p| {
185+
let package_path = workspace_root.resolve(&p.path);
186+
Package::new(p.name.to_string(), &workspace_root, &package_path)
187+
})
188+
.collect();
189+
190+
serializable_packages.sort_by_key(|p| p.name.clone());
191+
192+
Ok(serializable_packages)
193+
}
134194
}

0 commit comments

Comments
 (0)