Repository: lerna-lite/lerna-lite (ts)

Issue:
`catalog:` protocol changes are not correctly tracked
### Describe the bug

If a package has no changes while a peer with `catalog:` protocol is changes, this pacakge won't be bumped through `lerna version`

## Reproduction

See https://github.com/vuepress/ecosystem/blob/5ff0b92d4fca0cbf35ccf64883c8679beadfd5b9/pnpm-workspace.yaml

We are bumping vuepress  from rc20 to rc21 in this commit, and all of our packages are announcing vuepress as peer with `catalog:`, so `lerna version` should bump all packages.

However, when running `lerna version` that creates https://github.com/vuepress/ecosystem/commit/3ae65c7fc6ce8b182bd88ed885a625e62dd01251, some packages failed to bump, e.g.: https://github.com/vuepress/ecosystem/commits/main/plugins/development/plugin-theme-data

See https://github.com/vuepress/ecosystem/blob/main/plugins/development/plugin-theme-data/package.json#L44, this line actually changes.

### Expectation

lerna can correctly bump these versions.

Note: Republish with `lerna verison --force-publish` can bypass this issue, so this issue is not urgent


Pull request:
fix(version): pnpm catalog changes should be reflected in packages
<!--- Provide a general summary of your changes in the Title above -->

## Description

When dependencies in pnpm `catalog:` changes, it should detect that there was a change in that package and a version bump is required for that package (even when dep with `catalog:` text itself never changed, so we need a way to track that the catalog dep version changed and we advise the package that something changed and it requires a package version bump when running `lerna version`)

## Motivation and Context

fixes #1010 (partially, since local peer deps will not be supported)

The beauty about pnpm `catalog:` is that we can simply replace any packages that use multiple dependencies across the monorepo and have 1 central place to control which version to use across the entire solution (ie: installing VueJS in multiple packages but use the exact same version everywhere) which is great but.... this however brings a negative side effect for Lerna-Lite in the sense that we no longer see changes happening these packages anymore (because the word `catalog:` remains the same for the years to come). Because all the dependency changes are now only detected in a commit on the `pnpm-workspace.yaml` this adds a new problem, we can no longer detect packages that require version bump because everything happened in `pnpm-workspace.yaml` (at least not for the deps with `catalog:` because once they're changed to `catalog:` these deps will never be replaced, except when publishing, in the future and will no longer trigger a git commit change on that package). So how do we track these changes when it only happen in the central `pnpm-workspace.yaml`, this is what this PR is trying to resolve

## How Has This Been Tested?

No unit tests yet, I just briefly tested it locally by running it in dry-run mode but that's probably not sufficient. There are also failing unit tests to look into 

## Types of changes

<!--- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->

- [ ] Chore (change that has absolutely no effect on users)
- [x] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to change)

## Checklist:

<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
<!--- If you're unsure about any of these, don't hesitate to ask. We're here to help! -->

- [x] My code follows the code style of this project.
- [ ] My change requires a change to the documentation.
- [ ] I have updated the documentation accordingly.
- [ ] I have added tests to cover my changes.
- [x] All new and existing tests passed.


Patch:
diff --git a/.github/renovate.json5 b/.github/renovate.json5
index cfb04f7e..493bce0d 100644
--- a/.github/renovate.json5
+++ b/.github/renovate.json5
@@ -2,7 +2,7 @@
   extends: ['config:base', 'group:allNonMajor'],
   labels: ['dependencies'],
   ignorePaths: ['helpers', '**/__fixtures__/**', '**/__tests__/**'],
-  lockFileMaintenance: { enabled: true },
+  lockFileMaintenance: { enabled: false },
   pin: false,
   rangeStrategy: 'bump',
   packageRules: [
diff --git a/packages/core/package.json b/packages/core/package.json
index b9ee5b36..e32f340d 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -38,7 +38,7 @@
   "dependencies": {
     "@inquirer/expand": "^4.0.12",
     "@inquirer/input": "^4.1.9",
-    "@inquirer/select": "^4.1.1",
+    "@inquirer/select": "^4.2.0",
     "@lerna-lite/npmlog": "workspace:*",
     "@npmcli/run-script": "^9.1.0",
     "clone-deep": "^4.0.1",
diff --git a/packages/core/src/package-graph/package-graph.ts b/packages/core/src/package-graph/package-graph.ts
index d38275db..7f00b568 100644
--- a/packages/core/src/package-graph/package-graph.ts
+++ b/packages/core/src/package-graph/package-graph.ts
@@ -1,6 +1,6 @@
 import { log } from '@lerna-lite/npmlog';
-import fs from 'node:fs';
-import path from 'node:path';
+import { existsSync, readFileSync } from 'node:fs';
+import { join } from 'node:path';
 import npa from 'npm-package-arg';
 import { parse } from 'yaml';
 
@@ -31,6 +31,9 @@ export const YARN_PATCH_PROTOCOL_REG_EXP = new RegExp(`patch:(.*)@npm%3A(.*)#~\\
  * @extends {Map<string, PackageGraphNode>}
  */
 export class PackageGraph extends Map<string, PackageGraphNode> {
+  /** does the workspace project use pnpm catalog? */
+  hasWorkspaceCatalog = false;
+
   /**
    * @param {Package[]} packages - An array of Packages to build the graph out of.
    * @param {'allDependencies' | 'dependencies'} [graphType]
@@ -71,6 +74,10 @@ export class PackageGraph extends Map<string, PackageGraphNode> {
 
     const { catalog, catalogs } = this.readWorkspaceCatalogConfig();
 
+    if (Object.keys(catalog).length > 0 || Object.keys(catalogs).length > 0) {
+      this.hasWorkspaceCatalog = true;
+    }
+
     this.forEach((currentNode: PackageGraphNode, currentName: string) => {
       const graphDependencies =
         graphType === 'dependencies'
@@ -183,9 +190,9 @@ export class PackageGraph extends Map<string, PackageGraphNode> {
   }
 
   readWorkspaceCatalogConfig() {
-    const workspaceConfigPath = path.join(process.cwd(), 'pnpm-workspace.yaml');
-    const { catalog = {}, catalogs = {} } = fs.existsSync(workspaceConfigPath)
-      ? parse(fs.readFileSync(workspaceConfigPath, 'utf8'))
+    const workspaceConfigPath = join(process.cwd(), 'pnpm-workspace.yaml');
+    const { catalog = {}, catalogs = {} } = existsSync(workspaceConfigPath)
+      ? parse(readFileSync(workspaceConfigPath, 'utf8'))
       : {};
 
     return {
diff --git a/packages/core/src/utils/collect-updates/collect-updates.ts b/packages/core/src/utils/collect-updates/collect-updates.ts
index b0d9aa6f..3f90dc56 100644
--- a/packages/core/src/utils/collect-updates/collect-updates.ts
+++ b/packages/core/src/utils/collect-updates/collect-updates.ts
@@ -7,7 +7,7 @@ import { describeRefSync } from '../describe-ref.js';
 import { collectPackages } from './lib/collect-packages.js';
 import { getPackagesForOption } from './lib/get-packages-for-option.js';
 import { hasTags } from './lib/has-tags.js';
-import { makeDiffPredicate } from './lib/make-diff-predicate.js';
+import { diffWorkspaceCatalog, makeDiffPredicate } from './lib/make-diff-predicate.js';
 
 /**
  * Create a list of graph nodes representing packages changed since the previous release, tagged or otherwise.
@@ -46,6 +46,7 @@ export function collectUpdates(
   let committish = commandOptions.since;
   const tagPattern = describeTag ? describeTag : isIndependent ? '*@*' : 'v*';
 
+  let prevTag = '';
   if (hasTags(execOpts, tagPattern)) {
     const describeOptions: DescribeRefOptions = {
       ...execOpts,
@@ -55,6 +56,7 @@ export function collectUpdates(
 
     // describe the last annotated tag in the current branch
     const { sha, refCount, lastTagName } = describeRefSync(describeOptions, commandOptions.includeMergedTags);
+    prevTag = lastTagName || '';
     // TODO: warn about dirty tree?
 
     if (refCount === '0' && forced.size === 0 && !committish) {
@@ -101,9 +103,20 @@ export function collectUpdates(
 
   log.info('', `Looking for changed packages since ${committish}`);
 
-  const hasDiff = makeDiffPredicate(committish as string, execOpts, commandOptions.ignoreChanges as string[], {
-    independentSubpackages,
-  });
+  const changedCatalogDeps: string[] = [];
+  if (prevTag && packageGraph.hasWorkspaceCatalog) {
+    changedCatalogDeps.push(...diffWorkspaceCatalog(prevTag));
+  }
+
+  const hasDiff = makeDiffPredicate(
+    committish as string,
+    execOpts,
+    commandOptions.ignoreChanges as string[],
+    changedCatalogDeps,
+    {
+      independentSubpackages,
+    }
+  );
   const needsBump =
     !commandOptions.bump || commandOptions.bump.startsWith('pre')
       ? () => false
diff --git a/packages/core/src/utils/collect-updates/lib/make-diff-predicate.ts b/packages/core/src/utils/collect-updates/lib/make-diff-predicate.ts
index f6261851..9fa8a483 100644
--- a/packages/core/src/utils/collect-updates/lib/make-diff-predicate.ts
+++ b/packages/core/src/utils/collect-updates/lib/make-diff-predicate.ts
@@ -1,21 +1,29 @@
 import { log } from '@lerna-lite/npmlog';
-import { globSync } from 'tinyglobby';
+import { join } from 'node:path';
 import { filter as minimatchFilter } from 'minimatch';
+import { readFileSync } from 'node:fs';
 import { dirname, relative } from 'node:path';
 import slash from 'slash';
+import { globSync } from 'tinyglobby';
+import { parse } from 'yaml';
 
 import { execSync } from '../../../child-process.js';
 import type { ExecOpts } from '../../../models/interfaces.js';
+import { type PackageGraphNode } from '../../../package-graph/lib/package-graph-node.js';
 
 /**
  * @param {string} committish
- * @param {import("@lerna/child-process").ExecOpts} execOpts
- * @param {string[]} ignorePatterns
+ * @param {ExecOpts} execOpts
+ * @param {string[]} ignorePatterns - package patterns to ignore
+ * @param {string[]} changedCatalogDeps - dependencies that changed in the workspace catalog
+ * @param {object} diffOpts - options for diff
+ * @param {boolean} diffOpts.independentSubpackages - whether to include independent subpackages in the diff
  */
 export function makeDiffPredicate(
   committish: string,
   execOpts: ExecOpts,
   ignorePatterns: string[] = [],
+  changedCatalogDeps: string[] = [],
   diffOpts: { independentSubpackages?: boolean }
 ) {
   const ignoreFilters = new Set(
@@ -32,30 +40,41 @@ export function makeDiffPredicate(
     log.info('ignoring diff in paths matching', ignorePatterns.join(' '));
   }
 
-  return function hasDiffSinceThatIsntIgnored(/** @type {import("@lerna/package-graph").PackageGraphNode} */ node) {
+  return function hasDiffSinceThatIsntIgnored(node: PackageGraphNode) {
+    let hasDiff = true;
     const diff = diffSinceIn(committish, node.location, execOpts, diffOpts);
 
     if (diff === '') {
       log.silly('', 'no diff found in %s', node.name);
-      return false;
+      hasDiff = false;
     }
 
-    log.silly('found diff in', diff);
-    let changedFiles = diff.split('\n');
+    if (hasDiff) {
+      log.silly('found diff in', diff);
+      let changedFiles = diff.split('\n');
 
-    if (ignoreFilters.size) {
-      for (const ignored of ignoreFilters) {
-        changedFiles = changedFiles.filter(ignored);
+      if (ignoreFilters.size) {
+        for (const ignored of ignoreFilters) {
+          changedFiles = changedFiles.filter(ignored);
+        }
+      }
+
+      hasDiff = changedFiles.length > 0;
+
+      if (hasDiff) {
+        log.verbose('filtered diff', changedFiles.join(' '));
+      } else {
+        log.verbose('', 'no diff found in %s (after filtering)', node.name);
       }
     }
 
-    if (changedFiles.length) {
-      log.verbose('filtered diff', changedFiles.join(' '));
-    } else {
-      log.verbose('', 'no diff found in %s (after filtering)', node.name);
+    // last check, user might use pnpm catalog, if so check if current node package has changes found in catalog
+    if (!hasDiff && Array.from(node.externalDependencies).some(([depName]) => changedCatalogDeps.includes(depName))) {
+      log.silly('', 'diff found in catalog dependencies on package %s', node.name);
+      hasDiff = true;
     }
 
-    return changedFiles.length > 0;
+    return hasDiff;
   };
 }
 
@@ -88,3 +107,53 @@ function diffSinceIn(committish: string, location: string, execOpts: ExecOpts, d
   log.silly('checking diff', formattedLocation);
   return execSync('git', args, execOpts);
 }
+
+/**
+ * When using pnpm workspace catalog(s), we will compare current catalogs against the previous commited catalogs
+ * and return dependencies that changed since then.
+ */
+export function diffWorkspaceCatalog(prevTag: string): string[] {
+  const changedDependencies: string[] = [];
+  try {
+    // Get the previous commit's file contents
+    const prevWorkspaceContent = execSync('git', ['show', `${prevTag}:pnpm-workspace.yaml`]);
+
+    // Get the current commit's file contents
+    const workspaceConfigPath = join(process.cwd(), 'pnpm-workspace.yaml');
+    const currentWorkspaceContent = readFileSync(workspaceConfigPath, 'utf8');
+
+    // Parse the YAML files
+    const previousConfig = parse(prevWorkspaceContent);
+    const currentConfig = parse(currentWorkspaceContent);
+
+    // If either config is missing catalog, fallback to diff
+    if (currentConfig.catalog) {
+      // Find the changed dependencies
+      Object.keys(currentConfig.catalog).forEach((key) => {
+        if (!previousConfig.catalog[key] || previousConfig.catalog[key] !== currentConfig.catalog[key]) {
+          changedDependencies.push(key);
+        }
+      });
+
+      const diffOutput = execSync('git', ['diff', `${prevTag}..HEAD`, '--', 'pnpm-workspace.yaml']);
+      const diffLines = diffOutput.split('\n');
+      diffLines.forEach((line) => {
+        if (line.startsWith('+  ') && line.includes(':')) {
+          const key = line.substring(3).split(':')[0].trim();
+          if (key && !changedDependencies.includes(key)) {
+            changedDependencies.push(key);
+          }
+        } else if (line.startsWith('+ catalog.') && line.includes(':')) {
+          const key = line.substring(10).split(':')[0].trim();
+          if (key && !changedDependencies.includes(key)) {
+            changedDependencies.push(key);
+          }
+        }
+      });
+    }
+  } catch {
+    // do nothing, an empty array will be returned
+  }
+
+  return changedDependencies;
+}
diff --git a/packages/publish/README.md b/packages/publish/README.md
index 9645ecd3..8de8bd64 100644
--- a/packages/publish/README.md
+++ b/packages/publish/README.md
@@ -582,8 +582,7 @@ lerna will run [npm lifecycle scripts](https://docs.npmjs.com/cli/v8/using-npm/s
 The `catalog:` protocol ([pnpm catalog](https://pnpm.io/catalogs)) can be recognized by Lerna-Lite. When publishing, they will be replaced "as is" by reading and using the version range defined in your global catalog. If you need to bump the version of a package in a catalog, you will need to edit `pnpm-workspace.yaml` manually. If you wish them to be bumped automatically, then we strongly suggest that you use the [`workspace:`](#workspace-protocol) protocol instead which is better for local workspace dependencies.
 
 > [!NOTE]
-> 1. Catalog are not currently being tracked by packages, maybe in the future, it will not cause or detect changes in your packages. For example if your `pkg-1` has `"vite": catalog:` dependency and Vite version is updated in your `pnpm-workspace.yaml`, no changes will be detected in your `pkg-1` because there was no commit made in that package.
-> 2. Also, Lerna-Lite will only ever read the catalog (to get versions), but it will **never write** to it. If you want version bump then you should use `workspace:` for local dependencies.
+> Lerna-Lite will only ever read the catalog (to get versions), but it will **never write** to it. If you want version bump then you should use `workspace:` for local dependencies.
 
 So for example, if our `pnpm-workspace.yaml` file has the following configuration
 
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index cd152277..2d1d01da 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -287,8 +287,8 @@ importers:
         specifier: ^4.1.9
         version: 4.1.9(@types/node@22.15.2)
       '@inquirer/select':
-        specifier: ^4.1.1
-        version: 4.1.1(@types/node@22.15.2)
+        specifier: ^4.2.0
+        version: 4.2.0(@types/node@22.15.2)
       '@lerna-lite/npmlog':
         specifier: workspace:*
         version: link:../npmlog
@@ -1177,8 +1177,8 @@ packages:
       '@types/node':
         optional: true
 
-  '@inquirer/select@4.1.1':
-    resolution: {integrity: sha512-IUXzzTKVdiVNMA+2yUvPxWsSgOG4kfX93jOM4Zb5FgujeInotv5SPIJVeXQ+fO4xu7tW8VowFhdG5JRmmCyQ1Q==}
+  '@inquirer/select@4.2.0':
+    resolution: {integrity: sha512-KkXQ4aSySWimpV4V/TUJWdB3tdfENZUU765GjOIZ0uPwdbGIG6jrxD4dDf1w68uP+DVtfNhr1A92B+0mbTZ8FA==}
     engines: {node: '>=18'}
     peerDependencies:
       '@types/node': '>=18'
@@ -3833,7 +3833,7 @@ snapshots:
     optionalDependencies:
       '@types/node': 22.15.2
 
-  '@inquirer/select@4.1.1(@types/node@22.15.2)':
+  '@inquirer/select@4.2.0(@types/node@22.15.2)':
     dependencies:
       '@inquirer/core': 10.1.10(@types/node@22.15.2)
       '@inquirer/figures': 1.0.11


Test patch:
diff --git a/packages/core/src/utils/collect-updates/__tests__/collect-updates.spec.ts b/packages/core/src/utils/collect-updates/__tests__/collect-updates.spec.ts
index 7dd7e213..34f9cb3e 100644
--- a/packages/core/src/utils/collect-updates/__tests__/collect-updates.spec.ts
+++ b/packages/core/src/utils/collect-updates/__tests__/collect-updates.spec.ts
@@ -15,7 +15,7 @@ vi.mock('glob', async () => ({
 // mocked modules
 import { describeRefSync } from '../../describe-ref.js';
 import { hasTags } from '../lib/has-tags.js';
-import { makeDiffPredicate } from '../lib/make-diff-predicate.js';
+import { diffWorkspaceCatalog, makeDiffPredicate } from '../lib/make-diff-predicate.js';
 
 // helpers
 import buildGraph from '../__helpers__/build-graph.js';
@@ -41,6 +41,7 @@ const hasDiff = vi
   .mockImplementation((node) => changedPackages.has(node.name));
 
 (makeDiffPredicate as Mock).mockImplementation(() => hasDiff);
+(diffWorkspaceCatalog as Mock).mockReturnValue([]);
 
 // matcher constants
 const ALL_NODES = Object.freeze([
@@ -80,7 +81,7 @@ describe('collectUpdates()', () => {
     ]);
     expect(hasTags).toHaveBeenLastCalledWith(execOpts, 'v*');
     expect(describeRefSync).toHaveBeenLastCalledWith({ cwd: '/test', match: 'v*' }, undefined);
-    expect(makeDiffPredicate).toHaveBeenLastCalledWith('v1.0.0', execOpts, undefined, {
+    expect(makeDiffPredicate).toHaveBeenLastCalledWith('v1.0.0', execOpts, undefined, [], {
       independentSubpackages: undefined,
     });
   });
@@ -103,7 +104,7 @@ describe('collectUpdates()', () => {
     ]);
     expect(hasTags).toHaveBeenLastCalledWith(execOpts, '*@*');
     expect(describeRefSync).toHaveBeenLastCalledWith(execOpts, undefined);
-    expect(makeDiffPredicate).toHaveBeenLastCalledWith('v1.0.0', execOpts, undefined, {
+    expect(makeDiffPredicate).toHaveBeenLastCalledWith('v1.0.0', execOpts, undefined, [], {
       independentSubpackages: undefined,
     });
   });
@@ -398,7 +399,7 @@ describe('collectUpdates()', () => {
     });
 
     expect(updates).toEqual([expect.objectContaining({ name: 'package-dag-2a' }), expect.objectContaining({ name: 'package-dag-3' })]);
-    expect(makeDiffPredicate).toHaveBeenLastCalledWith('deadbeef^..deadbeef', execOpts, undefined, {
+    expect(makeDiffPredicate).toHaveBeenLastCalledWith('deadbeef^..deadbeef', execOpts, undefined, [], {
       independentSubpackages: undefined,
     });
   });
@@ -412,7 +413,7 @@ describe('collectUpdates()', () => {
       since: 'beefcafe',
     });
 
-    expect(makeDiffPredicate).toHaveBeenLastCalledWith('beefcafe', execOpts, undefined, {
+    expect(makeDiffPredicate).toHaveBeenLastCalledWith('beefcafe', execOpts, undefined, [], {
       independentSubpackages: undefined,
     });
   });
@@ -449,7 +450,7 @@ describe('collectUpdates()', () => {
       ignoreChanges: ['**/README.md'],
     });
 
-    expect(makeDiffPredicate).toHaveBeenLastCalledWith('v1.0.0', execOpts, ['**/README.md'], {
+    expect(makeDiffPredicate).toHaveBeenLastCalledWith('v1.0.0', execOpts, ['**/README.md'], [], {
       independentSubpackages: undefined,
     });
   });
@@ -464,7 +465,7 @@ describe('collectUpdates()', () => {
       independentSubpackages: true,
     });
 
-    expect(makeDiffPredicate).toHaveBeenLastCalledWith('v1.0.0', execOpts, undefined, {
+    expect(makeDiffPredicate).toHaveBeenLastCalledWith('v1.0.0', execOpts, undefined, [], {
       independentSubpackages: true,
     });
   });
diff --git a/packages/core/src/utils/collect-updates/__tests__/lib-make-diff-predicate.spec.ts b/packages/core/src/utils/collect-updates/__tests__/lib-make-diff-predicate.spec.ts
index adf9e10c..34505550 100644
--- a/packages/core/src/utils/collect-updates/__tests__/lib-make-diff-predicate.spec.ts
+++ b/packages/core/src/utils/collect-updates/__tests__/lib-make-diff-predicate.spec.ts
@@ -1,4 +1,4 @@
-import { expect, Mock, test, vi } from 'vitest';
+import { beforeEach, expect, Mock, test, vi } from 'vitest';
 
 vi.mock('../../../child-process');
 
@@ -10,21 +10,33 @@ vi.mock('tinyglobby', async () => ({
   ...(await vi.importActual('tinyglobby')),
   globSync: globMock,
 }));
+const { readFileMock } = vi.hoisted(() => ({ readFileMock: vi.fn() }));
+vi.mock('node:fs', async () => ({
+  ...(await vi.importActual('node:fs')),
+  readFileSync: readFileMock,
+}));
 
 // file under test
-import { makeDiffPredicate } from '../lib/make-diff-predicate.js';
+import { diffWorkspaceCatalog, makeDiffPredicate } from '../lib/make-diff-predicate.js';
+import { PackageGraphNode } from '../../../../dist/index.js';
 
 function setup(changes) {
   (childProcesses.execSync as Mock).mockReturnValueOnce([].concat(changes).join('\n'));
 }
 
+beforeEach(() => {
+  (readFileMock as Mock).mockReset();
+  (childProcesses.execSync as Mock).mockReset();
+});
+
 test('git diff call', () => {
   setup(['packages/pkg-1/__tests__/index.test.js', 'packages/pkg-1/index.js', 'packages/pkg-1/package.json', 'packages/pkg-1/README.md']);
 
-  const hasDiff = makeDiffPredicate('v1.0.0', { cwd: '/test' }, undefined, {});
+  const hasDiff = makeDiffPredicate('v1.0.0', { cwd: '/test' }, undefined, [], {});
   const result = hasDiff({
     location: '/test/packages/pkg-1',
-  });
+    externalDependencies: new Map([['pkg-2', {}]]),
+  } as PackageGraphNode);
 
   expect(result).toBe(true);
   expect(childProcesses.execSync).toHaveBeenLastCalledWith('git', ['diff', '--name-only', 'v1.0.0', '--', 'packages/pkg-1'], { cwd: '/test' });
@@ -33,10 +45,11 @@ test('git diff call', () => {
 test('empty diff', () => {
   setup('');
 
-  const hasDiff = makeDiffPredicate('v1.0.0', { cwd: '/test' }, undefined, {});
+  const hasDiff = makeDiffPredicate('v1.0.0', { cwd: '/test' }, undefined, [], {});
   const result = hasDiff({
     location: '/test/packages/pkg-1',
-  });
+    externalDependencies: new Map([['pkg-2', {}]]),
+  } as PackageGraphNode);
 
   expect(result).toBe(false);
 });
@@ -44,10 +57,11 @@ test('empty diff', () => {
 test('rooted package', () => {
   setup('package.json');
 
-  const hasDiff = makeDiffPredicate('deadbeef', { cwd: '/test' }, undefined, {});
+  const hasDiff = makeDiffPredicate('deadbeef', { cwd: '/test' }, undefined, [], {});
   const result = hasDiff({
     location: '/test',
-  });
+    externalDependencies: new Map([['pkg-2', {}]]),
+  } as PackageGraphNode);
 
   expect(result).toBe(true);
   expect(childProcesses.execSync).toHaveBeenLastCalledWith('git', ['diff', '--name-only', 'deadbeef'], {
@@ -58,10 +72,11 @@ test('rooted package', () => {
 test('ignore changes (globstars)', () => {
   setup(['packages/pkg-2/examples/.eslintrc.yaml', 'packages/pkg-2/examples/do-a-thing/index.js', 'packages/pkg-2/examples/and-another-thing/package.json']);
 
-  const hasDiff = makeDiffPredicate('v1.0.0', { cwd: '/test' }, ['**/examples/**', '*.md'], {});
+  const hasDiff = makeDiffPredicate('v1.0.0', { cwd: '/test' }, ['**/examples/**', '*.md'], [], {});
   const result = hasDiff({
     location: '/test/packages/pkg-2',
-  });
+    externalDependencies: new Map([['pkg-2', {}]]),
+  } as PackageGraphNode);
 
   expect(result).toBe(false);
 });
@@ -69,10 +84,11 @@ test('ignore changes (globstars)', () => {
 test('ignore changes (match base)', () => {
   setup('packages/pkg-3/README.md');
 
-  const hasDiff = makeDiffPredicate('v1.0.0', { cwd: '/test' }, ['*.md'], {});
+  const hasDiff = makeDiffPredicate('v1.0.0', { cwd: '/test' }, ['*.md'], [], {});
   const result = hasDiff({
     location: '/test/packages/pkg-3',
-  });
+    externalDependencies: new Map([['pkg-2', {}]]),
+  } as PackageGraphNode);
 
   expect(result).toBe(false);
 });
@@ -82,12 +98,13 @@ test('exclude subpackages when --independent-subpackages option is enabled and n
 
   setup(['packages/pkg-2/package.json', 'packages/pkg-2/do-a-thing/index.js', 'packages/pkg-2/and-another-thing/package.json']);
 
-  const hasDiff = makeDiffPredicate('v1.0.0', { cwd: '/test' }, ['**/examples/**', '*.md'], {
+  const hasDiff = makeDiffPredicate('v1.0.0', { cwd: '/test' }, ['**/examples/**', '*.md'], [], {
     independentSubpackages: true,
   });
   const result = hasDiff({
     location: '/test/packages/pkg-2',
-  });
+    externalDependencies: new Map([['pkg-2', {}]]),
+  } as PackageGraphNode);
 
   expect(result).toBe(true);
   expect(childProcesses.execSync).toHaveBeenLastCalledWith(
@@ -104,15 +121,128 @@ test('not exclude any subpackages when --independent-subpackages option is enabl
 
   setup(['packages/pkg-2/package.json', 'packages/pkg-2/do-a-thing/index.js', 'packages/pkg-2/and-another-thing/method.js']);
 
-  const hasDiff = makeDiffPredicate('v1.0.0', { cwd: '/test' }, ['**/examples/**', '*.md'], {
+  const hasDiff = makeDiffPredicate('v1.0.0', { cwd: '/test' }, ['**/examples/**', '*.md'], [], {
     independentSubpackages: true,
   });
   const result = hasDiff({
     location: '/test/packages/pkg-2',
-  });
+    externalDependencies: new Map([['pkg-2', {}]]),
+  } as PackageGraphNode);
 
   expect(result).toBe(true);
   expect(childProcesses.execSync).toHaveBeenLastCalledWith('git', ['diff', '--name-only', 'v1.0.0', '--', 'packages/pkg-2'], {
     cwd: '/test',
   });
 });
+
+test('diffWorkspaceCatalog with changes in pnpm workspace catalog', () => {
+  setup([]);
+
+  const hasDiff = makeDiffPredicate('v1.0.0', { cwd: '/test' }, undefined, ['vite'], {});
+  const result = hasDiff({
+    location: '/test/packages/pkg-1',
+    externalDependencies: new Map([['vite', {}]]),
+  } as PackageGraphNode);
+
+  expect(result).toBe(true);
+  expect(childProcesses.execSync).toHaveBeenLastCalledWith('git', ['diff', '--name-only', 'v1.0.0', '--', 'packages/pkg-1'], { cwd: '/test' });
+});
+
+test('diff workspace catalog returning dependencies that changed in the catalog', () => {
+  // make a diff on the catalog for the "execa" dependency
+  const mockPrevCatalogCommit = 'packages:\n  - packages/**\n\ncatalog:\n  execa: ^8.0.0\n  fs-extra: ^11.3.0';
+  const mockNewCatalogCommit = 'packages:\n  - packages/**\n\ncatalog:\n  execa: ^9.5.2\n  fs-extra: ^11.3.0';
+  const diff =
+    'diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml\nindex 1994993f..bf229582 100644\n--- a/pnpm-workspace.yaml\n+++ b/pnpm-workspace.yaml\n@@ -5,16 +5,16 @@ catalog:' +
+    '\n-  execa: ^8.0.1\n+  execa: ^9.5.2\n   fs-extra: ^11.3.0' +
+    'diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml\nindex 1994993f..bf229582 100644\n--- a/pnpm-workspace.yaml\n+++ b/pnpm-workspace.yaml' +
+    '\n@@ -11,13 +11,13 @@ catalog:\n-  execa: ^8.0.1\n+  execa: ^9.5.2\n   fs-extra: ^11.3.0\n@@ -30,7 +30,7';
+
+  (readFileMock as Mock).mockName('readFile').mockReturnValueOnce(mockNewCatalogCommit);
+  (childProcesses.execSync as Mock)
+    .mockReturnValueOnce(mockPrevCatalogCommit) // first call is to get previous catalog commit
+    .mockReturnValueOnce(diff); // next call is to get diff between current and previous catalog
+
+  const changes = diffWorkspaceCatalog('v1.0.0');
+  expect(changes).toEqual(['execa']);
+});
+
+test('diffWorkspaceCatalog returns changed dependencies when catalog values differ', () => {
+  const prev = 'packages:\n  - packages/**\n\ncatalog:\n  execa: ^8.0.0\n  fs-extra: ^11.3.0';
+  const curr = 'packages:\n  - packages/**\n\ncatalog:\n  execa: ^9.5.2\n  fs-extra: ^11.3.0';
+  (readFileMock as Mock).mockReturnValueOnce(curr);
+  (childProcesses.execSync as Mock).mockReturnValueOnce(prev);
+
+  const changes = diffWorkspaceCatalog('v1.0.0');
+  expect(changes).toEqual(['execa']);
+});
+
+test('diffWorkspaceCatalog returns multiple changed dependencies', () => {
+  const prev = 'packages:\n  - packages/**\n\ncatalog:\n  execa: ^8.0.0\n  fs-extra: ^10.0.0';
+  const curr = 'packages:\n  - packages/**\n\ncatalog:\n  execa: ^9.5.2\n  fs-extra: ^11.3.0';
+  (readFileMock as Mock).mockReturnValueOnce(curr);
+  (childProcesses.execSync as Mock).mockReturnValueOnce(prev);
+
+  const changes = diffWorkspaceCatalog('v1.0.0');
+  expect(changes.sort()).toEqual(['execa', 'fs-extra']);
+});
+
+test('diffWorkspaceCatalog returns empty array if no changes', () => {
+  const prev = 'packages:\n  - packages/**\n\ncatalog:\n  execa: ^9.5.2\n  fs-extra: ^11.3.0';
+  const curr = prev;
+  (readFileMock as Mock).mockReturnValueOnce(curr);
+  (childProcesses.execSync as Mock).mockReturnValueOnce(prev);
+
+  const changes = diffWorkspaceCatalog('v1.0.0');
+  expect(changes).toEqual([]);
+});
+
+test('diffWorkspaceCatalog returns empty array if catalog key is missing', () => {
+  const prev = 'packages:\n  - packages/**\n\n';
+  const curr = 'packages:\n  - packages/**\n\n';
+  (readFileMock as Mock).mockReturnValueOnce(curr);
+  (childProcesses.execSync as Mock).mockReturnValueOnce(prev);
+
+  const changes = diffWorkspaceCatalog('v1.0.0');
+  expect(changes).toEqual([]);
+});
+
+test('diffWorkspaceCatalog returns empty array if all methods fail', () => {
+  (readFileMock as Mock).mockImplementationOnce(() => {
+    throw new Error('fail');
+  });
+  (childProcesses.execSync as Mock)
+    .mockImplementationOnce(() => {
+      throw new Error('fail');
+    }) // fail YAML parse
+    .mockImplementationOnce(() => {
+      throw new Error('fail');
+    }); // fail git diff
+
+  const changes = diffWorkspaceCatalog('v1.0.0');
+  expect(changes).toEqual([]);
+});
+
+test('diffWorkspaceCatalog adds dependency from indented diff line when YAML shows no changes', () => {
+  const prev = 'packages:\n  - packages/**\n\ncatalog:\n  foo: ^1.0.0';
+  const curr = 'packages:\n  - packages/**\n\ncatalog:\n  foo: ^1.0.0';
+  (readFileMock as Mock).mockReturnValueOnce(curr);
+  (childProcesses.execSync as Mock)
+    .mockReturnValueOnce(prev) // previous YAML
+    .mockReturnValueOnce('+  bar: ^2.0.0\n'); // diff output
+
+  const changes = diffWorkspaceCatalog('v1.0.0');
+  expect(changes).toEqual(['bar']);
+});
+
+test('diffWorkspaceCatalog adds dependency from dot notation diff line when YAML shows no changes', () => {
+  const prev = 'packages:\n  - packages/**\n\ncatalog:\n  foo: ^1.0.0';
+  const curr = 'packages:\n  - packages/**\n\ncatalog:\n  foo: ^1.0.0';
+  (readFileMock as Mock).mockReturnValueOnce(curr);
+  (childProcesses.execSync as Mock)
+    .mockReturnValueOnce(prev) // previous YAML
+    .mockReturnValueOnce('+ catalog.baz: ^3.0.0\n'); // diff output
+
+  const changes = diffWorkspaceCatalog('v1.0.0');
+  expect(changes).toEqual(['baz']);
+});