bun-workspaces 1.8.2 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/AGENTS.md +537 -0
  2. package/README.md +51 -13
  3. package/package.json +1 -1
  4. package/src/2392.mjs +184 -3
  5. package/src/5166.mjs +1 -0
  6. package/src/8529.mjs +10 -0
  7. package/src/affected/affectedBaseRef.mjs +12 -0
  8. package/src/affected/externalDependencyChanges.mjs +47 -0
  9. package/src/affected/fileAffectedWorkspaces.mjs +152 -54
  10. package/src/affected/gitAffectedFiles.mjs +44 -1
  11. package/src/affected/gitAffectedWorkspaces.mjs +73 -3
  12. package/src/affected/index.mjs +2 -0
  13. package/src/ai/mcp/serverState.mjs +1 -1
  14. package/src/cli/commands/commandHandlerUtils.mjs +12 -7
  15. package/src/cli/commands/commands.mjs +4 -1
  16. package/src/cli/commands/handleSimpleCommands.mjs +2 -2
  17. package/src/cli/commands/listAffected.mjs +184 -0
  18. package/src/cli/commands/runScript/handleRunAffected.mjs +99 -0
  19. package/src/cli/commands/runScript/handleRunScript.mjs +19 -202
  20. package/src/cli/commands/runScript/index.mjs +1 -0
  21. package/src/cli/commands/runScript/scriptRunFlow.mjs +213 -0
  22. package/src/cli/index.d.ts +749 -134
  23. package/src/config/public.d.ts +66 -2
  24. package/src/config/rootConfig/rootConfig.mjs +9 -9
  25. package/src/config/rootConfig/rootConfigSchema.mjs +3 -0
  26. package/src/config/workspaceConfig/mergeWorkspaceConfig.mjs +33 -19
  27. package/src/config/workspaceConfig/workspaceConfig.mjs +3 -0
  28. package/src/config/workspaceConfig/workspaceConfigSchema.mjs +26 -0
  29. package/src/index.d.ts +307 -5
  30. package/src/index.mjs +1 -0
  31. package/src/internal/bun/bunLock.mjs +33 -0
  32. package/src/internal/generated/aiDocs/docs.mjs +169 -9
  33. package/src/internal/generated/ajv/validateRootConfig.mjs +1 -1
  34. package/src/internal/generated/ajv/validateWorkspaceConfig.mjs +1 -1
  35. package/src/project/implementations/fileSystemProject/affectedWorkspaces.mjs +227 -0
  36. package/src/project/implementations/{fileSystemProject.mjs → fileSystemProject/fileSystemProject.mjs} +169 -12
  37. package/src/project/implementations/fileSystemProject/index.mjs +4 -0
  38. package/src/project/implementations/memoryProject.mjs +1 -0
  39. package/src/project/implementations/projectBase.mjs +11 -17
  40. package/src/project/index.mjs +1 -1
  41. package/src/rslib-runtime.mjs +0 -31
  42. package/src/workspaces/applyWorkspacePatternConfigs.mjs +16 -2
  43. package/src/workspaces/dependencyGraph/resolveDependencies.mjs +68 -18
  44. package/src/workspaces/dependencyGraph/validateDependencyRules.mjs +14 -7
  45. package/src/workspaces/findWorkspaces.mjs +3 -0
  46. package/src/workspaces/workspace.mjs +8 -2
  47. package/src/workspaces/workspacePattern.mjs +134 -46
package/src/2392.mjs CHANGED
@@ -28,7 +28,8 @@ const CLI_COMMANDS_CONFIG = {
28
28
  options: {
29
29
  workspacePatterns: {
30
30
  flags: ["-W", "--workspace-patterns <patterns>"],
31
- description: "Workspace patterns to match, separated by spaces",
31
+ description:
32
+ "Workspace patterns to match, separated by whitespace. Use backslashes to escape spaces if needed.",
32
33
  },
33
34
  nameOnly: {
34
35
  flags: ["-n", "--name-only"],
@@ -144,6 +145,79 @@ const CLI_COMMANDS_CONFIG = {
144
145
  "Start the bun-workspaces MCP (Model Context Protocol) server over stdio",
145
146
  options: {},
146
147
  },
148
+ listAffected: {
149
+ command: "list-affected",
150
+ isGlobal: false,
151
+ aliases: ["ls-affected"],
152
+ description:
153
+ "List workspaces affected by a set of changed files (git or file list)",
154
+ options: {
155
+ base: {
156
+ flags: ["-B", "--base <ref>"],
157
+ description:
158
+ "Git base ref to diff against (default is main if not configured). Cannot be used with --files",
159
+ },
160
+ head: {
161
+ flags: ["-H", "--head <ref>"],
162
+ description:
163
+ "Git head ref to diff against (default: HEAD). Cannot be used with --files",
164
+ },
165
+ files: {
166
+ flags: ["-F", "--files <files>"],
167
+ description:
168
+ "Changed files (paths/dirs/globs, '!' to exclude), separated by spaces. Use backslashes to escape spaces if needed. Bypasses git, so cannot be used with --base or --head.",
169
+ },
170
+ script: {
171
+ flags: ["-S", "--script <script>"],
172
+ description: "Resolve inputs for the named script",
173
+ },
174
+ ignoreUntracked: {
175
+ flags: ["--ignore-untracked"],
176
+ description: "Exclude untracked files",
177
+ },
178
+ ignoreUnstaged: {
179
+ flags: ["--ignore-unstaged"],
180
+ description: "Exclude unstaged files",
181
+ },
182
+ ignoreStaged: {
183
+ flags: ["--ignore-staged"],
184
+ description: "Exclude staged files",
185
+ },
186
+ ignoreUncommitted: {
187
+ flags: ["--ignore-uncommitted"],
188
+ description:
189
+ "Exclude all uncommitted changes (staged, unstaged, untracked)",
190
+ },
191
+ ignoreWorkspaceDeps: {
192
+ flags: ["--ignore-workspace-deps"],
193
+ description:
194
+ "Ignore workspace dependencies derived from package.json files",
195
+ },
196
+ ignoreExternalDeps: {
197
+ flags: ["--ignore-external-deps"],
198
+ description:
199
+ "Ignore changes to external dependencies (e.g. npm packages) versions in bun.lock",
200
+ },
201
+ explain: {
202
+ flags: ["-e", "--explain"],
203
+ description:
204
+ "Include changed-file counts and dependency reasons. With --json, outputs the full result object",
205
+ },
206
+ detailed: {
207
+ flags: ["-D", "--detailed"],
208
+ description:
209
+ "With --explain, render full per-file data and dependency edge chains",
210
+ },
211
+ json: {
212
+ flags: JSON_FLAGS,
213
+ description: "Output as JSON",
214
+ },
215
+ pretty: {
216
+ flags: PRETTY_FLAGS,
217
+ description: "Pretty print JSON",
218
+ },
219
+ },
220
+ },
147
221
  runScript: {
148
222
  command: "run-script [script] [workspacePatterns...]",
149
223
  isGlobal: false,
@@ -153,11 +227,118 @@ const CLI_COMMANDS_CONFIG = {
153
227
  options: {
154
228
  script: {
155
229
  flags: ["-S", "--script <script>"],
156
- description: "The script to run.",
230
+ description: "The script to run. (Alternative to positional argument)",
157
231
  },
158
232
  workspacePatterns: {
159
233
  flags: ["-W", "--workspace-patterns <patterns>"],
160
- description: "Workspace patterns to match, separated by spaces.",
234
+ description:
235
+ "Workspace patterns to match, separated by spaces. (Alternative to positional arguments)",
236
+ },
237
+ parallel: {
238
+ flags: ["-P", "--parallel [max]"],
239
+ description:
240
+ 'Run the scripts in parallel. Pass "false" for series, or a concurrency limit as a number, percentage ("50%"), "auto", "default", or"unbounded"',
241
+ },
242
+ args: {
243
+ flags: ["-a", "--args <args>"],
244
+ description: "Args to append to the script command",
245
+ },
246
+ outputStyle: {
247
+ flags: ["-o", "--output-style <style>"],
248
+ description: "The output style to use",
249
+ values: [...OUTPUT_STYLE_VALUES],
250
+ },
251
+ groupedLines: {
252
+ flags: ["-L", "--grouped-lines <count>"],
253
+ description: `With grouped output, the max preview lines (number or "auto", default "auto")`,
254
+ },
255
+ noPrefix: {
256
+ flags: ["-N", "--no-prefix"],
257
+ description: "(DEPRECATED) Use --output-style=plain instead",
258
+ deprecated: true,
259
+ },
260
+ inline: {
261
+ flags: ["-i", "--inline"],
262
+ description:
263
+ "Run the script as an inline command from the workspace directory",
264
+ },
265
+ inlineName: {
266
+ flags: ["-I", "--inline-name <name>"],
267
+ description: "An optional name for the script when --inline is passed",
268
+ },
269
+ shell: {
270
+ flags: ["-s", "--shell <shell>"],
271
+ values: [...SCRIPT_SHELL_OPTIONS, "default"],
272
+ description: `When using --inline, the shell to use to run the script`,
273
+ },
274
+ depOrder: {
275
+ flags: ["-d", "--dep-order"],
276
+ description:
277
+ "Scripts for dependent workspaces run only after their dependencies",
278
+ },
279
+ ignoreDepFailure: {
280
+ flags: ["-f", "--ignore-dep-failure"],
281
+ description:
282
+ "In dependency order, continue running scripts even if a dependency fails",
283
+ },
284
+ jsonOutfile: {
285
+ flags: ["-j", "--json-outfile <file>"],
286
+ description: "Output results in a JSON file",
287
+ },
288
+ },
289
+ },
290
+ runAffected: {
291
+ command: "run-affected [script]",
292
+ isGlobal: false,
293
+ aliases: [],
294
+ description:
295
+ "Run a script across the workspaces affected by a set of changed files (git or file list)",
296
+ options: {
297
+ script: {
298
+ flags: ["-S", "--script <script>"],
299
+ description: "The script to run. (Alternative to positional argument)",
300
+ },
301
+ base: {
302
+ flags: ["-B", "--base <ref>"],
303
+ description:
304
+ "Git base ref to diff against (default is main if not configured). Cannot be used with --files",
305
+ },
306
+ head: {
307
+ flags: ["-H", "--head <ref>"],
308
+ description:
309
+ 'Git head ref to diff against (default "HEAD"). Cannot be used with --files',
310
+ },
311
+ files: {
312
+ flags: ["-F", "--files <files>"],
313
+ description:
314
+ "Changed files (paths/dirs/globs, '!' to exclude), separated by whitespace. Use backslashes to escape spaces if needed. Bypasses git, so cannot be used with --base or --head.",
315
+ },
316
+ ignoreUntracked: {
317
+ flags: ["--ignore-untracked"],
318
+ description: "Exclude untracked files",
319
+ },
320
+ ignoreUnstaged: {
321
+ flags: ["--ignore-unstaged"],
322
+ description: "Exclude unstaged files",
323
+ },
324
+ ignoreStaged: {
325
+ flags: ["--ignore-staged"],
326
+ description: "Exclude staged files",
327
+ },
328
+ ignoreUncommitted: {
329
+ flags: ["--ignore-uncommitted"],
330
+ description:
331
+ "Exclude all uncommitted changes (staged, unstaged, untracked)",
332
+ },
333
+ ignoreWorkspaceDeps: {
334
+ flags: ["--ignore-workspace-deps"],
335
+ description:
336
+ "Ignore workspace dependencies derived from package.json files",
337
+ },
338
+ ignoreExternalDeps: {
339
+ flags: ["--ignore-external-deps"],
340
+ description:
341
+ "Ignore changes to external dependencies (e.g. npm packages) versions in bun.lock",
161
342
  },
162
343
  parallel: {
163
344
  flags: ["-P", "--parallel [max]"],
package/src/5166.mjs CHANGED
@@ -2,6 +2,7 @@ const USER_ENV_VARS = {
2
2
  parallelMaxDefault: "BW_PARALLEL_MAX_DEFAULT",
3
3
  scriptShellDefault: "BW_SHELL_DEFAULT",
4
4
  includeRootWorkspaceDefault: "BW_INCLUDE_ROOT_WORKSPACE_DEFAULT",
5
+ affectedBaseRefDefault: "BW_AFFECTED_BASE_REF_DEFAULT",
5
6
  };
6
7
  const getUserEnvVarName = (key) => USER_ENV_VARS[key];
7
8
 
package/src/8529.mjs CHANGED
@@ -16,8 +16,18 @@ const CONFIG_LOCATION_PATHS = {
16
16
  jsonFile: (name) => `${name}.json`,
17
17
  packageJson: (_, packageJsonKey) => `package.json["${packageJsonKey}"]`,
18
18
  };
19
+ const CONFIG_LOCATION_DESCRIPTIONS =
20
+ /* unused pure expression or super */ null && {
21
+ tsFile: "TypeScript file",
22
+ jsFile: "JavaScript file",
23
+ jsoncFile: "JSONC file",
24
+ jsonFile: "JSON file",
25
+ packageJson: "package.json key",
26
+ };
19
27
  const createConfigLocationPath = (locationType, name, packageJsonKey) =>
20
28
  CONFIG_LOCATION_PATHS[locationType](name, packageJsonKey);
29
+ const createConfigLocationDescription = (locationType) =>
30
+ CONFIG_LOCATION_DESCRIPTIONS[locationType];
21
31
 
22
32
  export {
23
33
  CONFIG_LOCATION_TYPES,
@@ -0,0 +1,12 @@
1
+ import { getUserEnvVar } from "../config/userEnvVars/index.mjs";
2
+
3
+ const DEFAULT_AFFECTED_BASE_REF = "main";
4
+ /**
5
+ * Resolves the default base ref for affected workspace resolution.
6
+ *
7
+ * Precedence: explicit value (typically from root config defaults) >
8
+ * `BW_AFFECTED_BASE_REF_DEFAULT` env var > `"main"`.
9
+ */ const resolveDefaultAffectedBaseRef = (value) =>
10
+ value || getUserEnvVar("affectedBaseRefDefault") || DEFAULT_AFFECTED_BASE_REF;
11
+
12
+ export { DEFAULT_AFFECTED_BASE_REF, resolveDefaultAffectedBaseRef };
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Resolve a dep's version for a specific workspace. bun.lock encodes
3
+ * divergent per-workspace resolutions under a `<workspaceName>/<depName>` key
4
+ * when the workspace's range can't dedupe with the hoisted version. Always
5
+ * consult that namespaced key first; fall back to the bare key for the
6
+ * common (hoisted) case.
7
+ */ const resolveWorkspaceDepVersion = ({ lock, workspaceName, depName }) =>
8
+ lock.get(`${workspaceName}/${depName}`) ?? lock.get(depName) ?? null;
9
+ /**
10
+ * Given each workspace's declared external deps and lockfile version maps
11
+ * at base vs head, emit per-workspace change entries for any external dep
12
+ * whose resolved version differs (including added/removed).
13
+ *
14
+ * Pure function. No I/O.
15
+ */ const computeExternalDependencyChanges = ({
16
+ workspaces,
17
+ baseLock,
18
+ headLock,
19
+ }) => {
20
+ const result = new Map();
21
+ for (const workspace of workspaces) {
22
+ const changes = [];
23
+ for (const { name, source } of workspace.externalDependencies) {
24
+ const baseVersion = resolveWorkspaceDepVersion({
25
+ lock: baseLock,
26
+ workspaceName: workspace.name,
27
+ depName: name,
28
+ });
29
+ const headVersion = resolveWorkspaceDepVersion({
30
+ lock: headLock,
31
+ workspaceName: workspace.name,
32
+ depName: name,
33
+ });
34
+ if (baseVersion === headVersion) continue;
35
+ changes.push({
36
+ name,
37
+ source,
38
+ baseVersion,
39
+ headVersion,
40
+ });
41
+ }
42
+ if (changes.length) result.set(workspace.name, changes);
43
+ }
44
+ return result;
45
+ };
46
+
47
+ export { computeExternalDependencyChanges };
@@ -8,10 +8,15 @@ const GLOB_CHARACTER_REGEX = /[*?[{]/;
8
8
  const toPosixPath = (filePath) => filePath.replaceAll("\\", "/");
9
9
  const stripTrailingSlashes = (filePath) => filePath.replace(/\/+$/, "");
10
10
  const stripLeadingSlashes = (filePath) => filePath.replace(/^\/+/, "");
11
+ const stripDotSlashSegments = (filePath) => {
12
+ let stripped = filePath;
13
+ while (stripped.startsWith("./")) stripped = stripped.slice(2);
14
+ return stripped === "." ? "" : stripped;
15
+ };
11
16
  const normalizeChangedFilePath = ({ rootDirectory, filePath }) => {
12
17
  const posixFilePath = toPosixPath(filePath);
13
18
  if (!path.isAbsolute(filePath)) {
14
- return posixFilePath;
19
+ return stripDotSlashSegments(posixFilePath);
15
20
  }
16
21
  const posixRoot = stripTrailingSlashes(toPosixPath(rootDirectory));
17
22
  if (posixFilePath === posixRoot) {
@@ -142,7 +147,10 @@ const matchChangedFilesForWorkspace = ({
142
147
  }
143
148
  return matchedFiles;
144
149
  };
145
- const resolveInputWorkspaceDependencies = ({ workspaceInputs }) => {
150
+ const resolveInputWorkspaceDependencies = ({
151
+ workspaceInputs,
152
+ rootWorkspace,
153
+ }) => {
146
154
  const inputDependenciesByName = new Map();
147
155
  const allWorkspaces = workspaceInputs.map(({ workspace }) => workspace);
148
156
  for (const { workspace, inputWorkspacePatterns } of workspaceInputs) {
@@ -153,6 +161,7 @@ const resolveInputWorkspaceDependencies = ({ workspaceInputs }) => {
153
161
  const matchedNames = matchWorkspacesByPatterns(
154
162
  inputWorkspacePatterns,
155
163
  allWorkspaces,
164
+ rootWorkspace,
156
165
  )
157
166
  .map((matchedWorkspace) => matchedWorkspace.name)
158
167
  .filter((matchedName) => matchedName !== workspace.name);
@@ -160,12 +169,36 @@ const resolveInputWorkspaceDependencies = ({ workspaceInputs }) => {
160
169
  }
161
170
  return inputDependenciesByName;
162
171
  };
172
+ const collectDirectEdges = ({
173
+ workspace,
174
+ inputDependenciesByName,
175
+ ignoreWorkspaceDependencies,
176
+ }) => {
177
+ const edges = [];
178
+ for (const dependencyName of inputDependenciesByName.get(workspace.name) ??
179
+ []) {
180
+ edges.push({
181
+ dependencyName,
182
+ edgeSource: "input",
183
+ });
184
+ }
185
+ if (!ignoreWorkspaceDependencies) {
186
+ for (const dependencyName of workspace.dependencies) {
187
+ edges.push({
188
+ dependencyName,
189
+ edgeSource: "package",
190
+ });
191
+ }
192
+ }
193
+ return edges;
194
+ };
163
195
  const computeAffectedWorkspaceSet = ({
164
196
  workspaceInputs,
165
197
  workspaceByName,
166
198
  changedFilesByName,
199
+ externalDepChangesByWorkspace,
167
200
  inputDependenciesByName,
168
- ignorePackageDependencies,
201
+ ignoreWorkspaceDependencies,
169
202
  }) => {
170
203
  const inputDependentsByName = new Map();
171
204
  for (const [workspaceName, dependencyNames] of inputDependenciesByName) {
@@ -181,7 +214,11 @@ const computeAffectedWorkspaceSet = ({
181
214
  const affected = new Set();
182
215
  const queue = [];
183
216
  for (const { workspace } of workspaceInputs) {
184
- if ((changedFilesByName.get(workspace.name)?.length ?? 0) > 0) {
217
+ const hasChangedFiles =
218
+ (changedFilesByName.get(workspace.name)?.length ?? 0) > 0;
219
+ const hasExternalDepChanges =
220
+ (externalDepChangesByWorkspace.get(workspace.name)?.length ?? 0) > 0;
221
+ if (hasChangedFiles || hasExternalDepChanges) {
185
222
  affected.add(workspace.name);
186
223
  queue.push(workspace.name);
187
224
  }
@@ -191,7 +228,7 @@ const computeAffectedWorkspaceSet = ({
191
228
  const currentWorkspace = workspaceByName.get(currentName);
192
229
  const dependents = [
193
230
  ...(inputDependentsByName.get(currentName) ?? []),
194
- ...(!ignorePackageDependencies && currentWorkspace
231
+ ...(!ignoreWorkspaceDependencies && currentWorkspace
195
232
  ? currentWorkspace.dependents
196
233
  : []),
197
234
  ];
@@ -204,66 +241,118 @@ const computeAffectedWorkspaceSet = ({
204
241
  }
205
242
  return affected;
206
243
  };
244
+ /**
245
+ * Walk forward from `directDependencyName` through the affected dep graph,
246
+ * appending each next affected dep edge to the chain until we run out of
247
+ * affected dep edges to follow. Stops on:
248
+ * - no further affected dep edges,
249
+ * - revisiting a workspace already in the chain (cycle).
250
+ *
251
+ * Branching is broken deterministically by edge insertion order
252
+ * (input edges before package edges, declaration order within each).
253
+ */ const extendChainThroughAffectedDeps = ({
254
+ startingWorkspaceName,
255
+ directDependencyName,
256
+ directEdgeSource,
257
+ workspaceByName,
258
+ inputDependenciesByName,
259
+ affectedSet,
260
+ ignoreWorkspaceDependencies,
261
+ }) => {
262
+ const chain = [
263
+ {
264
+ workspaceName: startingWorkspaceName,
265
+ },
266
+ {
267
+ workspaceName: directDependencyName,
268
+ edgeSource: directEdgeSource,
269
+ },
270
+ ];
271
+ const visited = new Set([startingWorkspaceName, directDependencyName]);
272
+ let currentName = directDependencyName;
273
+ while (true) {
274
+ const currentWorkspace = workspaceByName.get(currentName);
275
+ if (!currentWorkspace) break;
276
+ const nextEdge = collectDirectEdges({
277
+ workspace: currentWorkspace,
278
+ inputDependenciesByName,
279
+ ignoreWorkspaceDependencies,
280
+ }).find(
281
+ ({ dependencyName }) =>
282
+ !visited.has(dependencyName) &&
283
+ workspaceByName.has(dependencyName) &&
284
+ affectedSet.has(dependencyName),
285
+ );
286
+ if (!nextEdge) break;
287
+ chain.push({
288
+ workspaceName: nextEdge.dependencyName,
289
+ edgeSource: nextEdge.edgeSource,
290
+ });
291
+ visited.add(nextEdge.dependencyName);
292
+ currentName = nextEdge.dependencyName;
293
+ }
294
+ return chain;
295
+ };
207
296
  const collectAffectedDependencies = ({
208
297
  startingWorkspace,
209
298
  workspaceByName,
210
299
  inputDependenciesByName,
211
300
  affectedSet,
212
- ignorePackageDependencies,
301
+ ignoreWorkspaceDependencies,
213
302
  }) => {
214
303
  const results = [];
215
- const visited = new Set([startingWorkspace.name]);
216
- const visit = (currentName, chain) => {
217
- const currentWorkspace = workspaceByName.get(currentName);
218
- if (!currentWorkspace) return;
219
- const edges = [];
220
- for (const dependencyName of inputDependenciesByName.get(currentName) ??
221
- []) {
222
- edges.push({
223
- dependencyName,
224
- edgeSource: "input",
225
- });
226
- }
227
- if (!ignorePackageDependencies) {
228
- for (const dependencyName of currentWorkspace.dependencies) {
229
- edges.push({
230
- dependencyName,
231
- edgeSource: "package",
232
- });
233
- }
234
- }
235
- for (const { dependencyName, edgeSource } of edges) {
236
- if (visited.has(dependencyName)) continue;
237
- if (!workspaceByName.has(dependencyName)) continue;
238
- visited.add(dependencyName);
239
- const dependencyChain = [
240
- ...chain,
241
- {
242
- workspaceName: dependencyName,
243
- edgeSource,
244
- },
245
- ];
246
- if (affectedSet.has(dependencyName)) {
247
- results.push({
248
- dependencyName,
249
- chain: dependencyChain,
250
- });
251
- }
252
- visit(dependencyName, dependencyChain);
253
- }
254
- };
255
- visit(startingWorkspace.name, [
256
- {
257
- workspaceName: startingWorkspace.name,
258
- },
259
- ]);
304
+ const seen = new Set([startingWorkspace.name]);
305
+ const directEdges = collectDirectEdges({
306
+ workspace: startingWorkspace,
307
+ inputDependenciesByName,
308
+ ignoreWorkspaceDependencies,
309
+ });
310
+ for (const { dependencyName, edgeSource } of directEdges) {
311
+ if (seen.has(dependencyName)) continue;
312
+ if (!workspaceByName.has(dependencyName)) continue;
313
+ seen.add(dependencyName);
314
+ if (!affectedSet.has(dependencyName)) continue;
315
+ results.push({
316
+ dependencyName,
317
+ chain: extendChainThroughAffectedDeps({
318
+ startingWorkspaceName: startingWorkspace.name,
319
+ directDependencyName: dependencyName,
320
+ directEdgeSource: edgeSource,
321
+ workspaceByName,
322
+ inputDependenciesByName,
323
+ affectedSet,
324
+ ignoreWorkspaceDependencies,
325
+ }),
326
+ });
327
+ }
260
328
  return results;
261
329
  };
330
+ const filterExternalDepChangesByInputs = ({
331
+ changesByWorkspace,
332
+ workspaceInputs,
333
+ }) => {
334
+ const filtered = new Map();
335
+ for (const { workspace, inputExternalDependencyNames } of workspaceInputs) {
336
+ const changes = changesByWorkspace.get(workspace.name);
337
+ if (!changes?.length) continue;
338
+ if (inputExternalDependencyNames === undefined) {
339
+ filtered.set(workspace.name, changes);
340
+ continue;
341
+ }
342
+ if (inputExternalDependencyNames.length === 0) continue;
343
+ const allowed = new Set(inputExternalDependencyNames);
344
+ const matched = changes.filter((change) => allowed.has(change.name));
345
+ if (matched.length) filtered.set(workspace.name, matched);
346
+ }
347
+ return filtered;
348
+ };
262
349
  const getFileAffectedWorkspaces = async ({
263
350
  rootDirectory,
264
351
  workspaceInputs,
265
352
  changedFilePaths,
266
- ignorePackageDependencies = false,
353
+ rootWorkspace,
354
+ externalDepChangesByWorkspace = new Map(),
355
+ ignoreWorkspaceDependencies = false,
267
356
  }) => {
268
357
  const normalizedChangedFilePaths = changedFilePaths.map((filePath) =>
269
358
  normalizeChangedFilePath({
@@ -285,24 +374,32 @@ const getFileAffectedWorkspaces = async ({
285
374
  }),
286
375
  );
287
376
  }
377
+ const filteredExternalDepChanges = filterExternalDepChangesByInputs({
378
+ changesByWorkspace: externalDepChangesByWorkspace,
379
+ workspaceInputs,
380
+ });
288
381
  const inputDependenciesByName = resolveInputWorkspaceDependencies({
289
382
  workspaceInputs,
383
+ rootWorkspace,
290
384
  });
291
385
  const affectedSet = computeAffectedWorkspaceSet({
292
386
  workspaceInputs,
293
387
  workspaceByName,
294
388
  changedFilesByName,
389
+ externalDepChangesByWorkspace: filteredExternalDepChanges,
295
390
  inputDependenciesByName,
296
- ignorePackageDependencies,
391
+ ignoreWorkspaceDependencies,
297
392
  });
298
393
  const affectedWorkspaces = workspaceInputs.map(({ workspace }) => {
299
394
  const changedFiles = changedFilesByName.get(workspace.name) ?? [];
395
+ const externalDependencies =
396
+ filteredExternalDepChanges.get(workspace.name) ?? [];
300
397
  const dependencies = collectAffectedDependencies({
301
398
  startingWorkspace: workspace,
302
399
  workspaceByName,
303
400
  inputDependenciesByName,
304
401
  affectedSet,
305
- ignorePackageDependencies,
402
+ ignoreWorkspaceDependencies,
306
403
  });
307
404
  return {
308
405
  workspace,
@@ -310,6 +407,7 @@ const getFileAffectedWorkspaces = async ({
310
407
  affectedReasons: {
311
408
  changedFiles,
312
409
  dependencies,
410
+ externalDependencies,
313
411
  },
314
412
  };
315
413
  });
@@ -52,6 +52,38 @@ const resolveGitRoot = async (rootDirectory) => {
52
52
  }
53
53
  return result.stdout.trim();
54
54
  };
55
+ /**
56
+ * Read a project-root-relative file's contents at a specific git ref via
57
+ * `git show <ref>:<repo-relative-path>`. Returns `null` if the file does not
58
+ * exist at that ref (e.g. it was added later). Throws on other git errors.
59
+ */ const readProjectFileAtGitRef = async ({
60
+ rootDirectory,
61
+ ref,
62
+ projectRelativePath,
63
+ }) => {
64
+ const gitRoot = fs.realpathSync.native(
65
+ path.resolve(await resolveGitRoot(rootDirectory)),
66
+ );
67
+ const absoluteProjectRoot = fs.realpathSync.native(
68
+ path.resolve(rootDirectory),
69
+ );
70
+ const absoluteFile = path.resolve(absoluteProjectRoot, projectRelativePath);
71
+ const repoRelative = path
72
+ .relative(gitRoot, absoluteFile)
73
+ .split(path.sep)
74
+ .join("/");
75
+ const result = await runGit(["show", `${ref}:${repoRelative}`], gitRoot);
76
+ if (result.exitCode === 0) return result.stdout;
77
+ if (
78
+ result.stderr.includes("does not exist") ||
79
+ result.stderr.includes("exists on disk, but not in")
80
+ ) {
81
+ return null;
82
+ }
83
+ throw new GIT_AFFECTED_ERRORS.GitCommandFailed(
84
+ `git show ${ref}:${repoRelative} failed (exit ${result.exitCode}): ${result.stderr.trim()}`,
85
+ );
86
+ };
55
87
  const toProjectFilePath = ({
56
88
  gitRoot,
57
89
  absoluteProjectRoot,
@@ -83,6 +115,10 @@ const getGitAffectedFiles = async (options) => {
83
115
  const includeStaged = !ignoreUncommitted && !ignoreStaged;
84
116
  const includeUnstaged = !ignoreUncommitted && !ignoreUnstaged;
85
117
  const includeUntracked = !ignoreUncommitted && !ignoreUntracked;
118
+ const [baseSha, headSha] = await Promise.all([
119
+ runGitOrThrow(["rev-parse", baseRef], gitRoot).then((out) => out.trim()),
120
+ runGitOrThrow(["rev-parse", headRef], gitRoot).then((out) => out.trim()),
121
+ ]);
86
122
  const collectors = [
87
123
  runGitOrThrow(
88
124
  ["diff", "--name-only", "-z", baseRef, headRef],
@@ -147,7 +183,14 @@ const getGitAffectedFiles = async (options) => {
147
183
  .sort((a, b) => a.projectFilePath.localeCompare(b.projectFilePath));
148
184
  return {
149
185
  files,
186
+ baseSha,
187
+ headSha,
150
188
  };
151
189
  };
152
190
 
153
- export { GIT_AFFECTED_ERRORS, GIT_AFFECTED_FILE_REASONS, getGitAffectedFiles };
191
+ export {
192
+ GIT_AFFECTED_ERRORS,
193
+ GIT_AFFECTED_FILE_REASONS,
194
+ getGitAffectedFiles,
195
+ readProjectFileAtGitRef,
196
+ };