Repository: scolladon/sfdx-git-delta
Pull request #1047: feat: improve metadatadiff performance
Author: login: matdodgson
Created: 2025-04-07T23:11:54Z | Merged: 2025-04-12T08:23:12Z

# Explain your changes

The performance could be improved when looking for changes in large metadata files. In our project - where some metadata files are 30k lines long - it can take 10 seconds or more to compare changes in those files with SGD.

Node profiling showed that the hotspot was the call to differenceWith in getPartialContentWithKey. I refactored the code and there are now 2 functions for comparing objects:
- getPartialContentWithKey - used where there is a key field in the metadata definitions
- getPartialContentForObject - used where the metadata definition key is ```<object>```

I added a test to the PR. Using the original code in metadataDiff the test takes approx 5 seconds. Using the new code the test takes max 200mS on my PC.

# Does this close any currently open issues?

closes #1050 

# Any particular element that can be tested locally

I added a test in the PR.



Diff:
diff --git a/__tests__/unit/lib/utils/metadataDiff.test.ts b/__tests__/unit/lib/utils/metadataDiff.test.ts
index 13b94317b..dc8148785 100644
--- a/__tests__/unit/lib/utils/metadataDiff.test.ts
+++ b/__tests__/unit/lib/utils/metadataDiff.test.ts
@@ -195,7 +195,7 @@ const unTracked = {
   },
 }
 
-describe.each([[{}], [xmlHeader]])(`MetadataDiff`, header => {
+describe('MetadataDiff', () => {
   let metadataDiff: MetadataDiff
   let globalMetadata: MetadataRepository
   let inFileAttribute: Map<string, SharedFileMetadata>
@@ -211,257 +211,185 @@ describe.each([[{}], [xmlHeader]])(`MetadataDiff`, header => {
     work.config.to = 'to'
     metadataDiff = new MetadataDiff(work.config, inFileAttribute)
   })
+  describe.each([[{}], [xmlHeader]])(`with Header %j`, header => {
+    describe(`compare with ${JSON.stringify(header)} header`, () => {
+      it('does not detect null file content', async () => {
+        // Arrange
+        mockedParseXmlFileToJson.mockResolvedValueOnce('')
+        mockedParseXmlFileToJson.mockResolvedValueOnce('')
 
-  describe(`compare with ${JSON.stringify(header)} header`, () => {
-    it('does not detect null file content', async () => {
-      // Arrange
-      mockedParseXmlFileToJson.mockResolvedValueOnce('')
-      mockedParseXmlFileToJson.mockResolvedValueOnce('')
-
-      // Act
-      const { added, deleted } = await metadataDiff.compare('file/path')
-
-      // Assert
-      expect(deleted.size).toBe(0)
-      expect(added.size).toBe(0)
-    })
+        // Act
+        const { added, deleted } = await metadataDiff.compare('file/path')
 
-    it('do not detect not tracked elements', async () => {
-      // Arrange
-      mockedParseXmlFileToJson.mockResolvedValueOnce({
-        ...header,
-        ...unTracked,
+        // Assert
+        expect(deleted.size).toBe(0)
+        expect(added.size).toBe(0)
       })
-      mockedParseXmlFileToJson.mockResolvedValueOnce({ ...header, ...wfBase })
-
-      // Act
-      const { added, deleted } = await metadataDiff.compare('file/path')
-
-      // Assert
-      expect(deleted.size).toBe(0)
-      expect(added.size).toBe(0)
-    })
-
-    it('detects added elements', async () => {
-      // Arrange
-      mockedParseXmlFileToJson.mockResolvedValueOnce({ ...header, ...alert })
-      mockedParseXmlFileToJson.mockResolvedValueOnce({ ...header, ...wfBase })
 
-      // Act
-      const { added, deleted } = await metadataDiff.compare('file/path')
-
-      // Assert
-      expect(deleted.size).toBe(0)
-      expect(added.get('WorkflowAlert')).toEqual(
-        new Set(['OtherTestEmailAlert', 'TestEmailAlert'])
-      )
-    })
-    it('detects removed elements', async () => {
-      // Arrange
-      mockedParseXmlFileToJson.mockResolvedValueOnce({ ...header, ...wfBase })
-      mockedParseXmlFileToJson.mockResolvedValueOnce({ ...header, ...alert })
-
-      // Act
-      const { added, deleted } = await metadataDiff.compare('file/path')
+      it('do not detect not tracked elements', async () => {
+        // Arrange
+        mockedParseXmlFileToJson.mockResolvedValueOnce({
+          ...header,
+          ...unTracked,
+        })
+        mockedParseXmlFileToJson.mockResolvedValueOnce({ ...header, ...wfBase })
 
-      // Assert
-      expect(added.size).toBe(0)
-      expect(deleted.get('WorkflowAlert')).toEqual(
-        new Set(['OtherTestEmailAlert', 'TestEmailAlert'])
-      )
-    })
+        // Act
+        const { added, deleted } = await metadataDiff.compare('file/path')
 
-    it('detects parsed empty elements', async () => {
-      // Arrange
-      mockedParseXmlFileToJson.mockResolvedValueOnce({
-        ...header,
-        ...{
-          Workflow: {
-            '@_xmlns': 'http://soap.sforce.com/2006/04/metadata',
-            alerts: [],
-          },
-        },
+        // Assert
+        expect(deleted.size).toBe(0)
+        expect(added.size).toBe(0)
       })
-      mockedParseXmlFileToJson.mockResolvedValueOnce({ ...header, ...alert })
 
-      // Act
-      const { added, deleted } = await metadataDiff.compare('file/path')
-
-      // Assert
-      expect(added.size).toBe(0)
-      expect(deleted.get('WorkflowAlert')).toEqual(
-        new Set(['OtherTestEmailAlert', 'TestEmailAlert'])
-      )
-    })
-
-    it('detects deleted file', async () => {
-      // Arrange
-      mockedParseXmlFileToJson.mockResolvedValueOnce('')
-      mockedParseXmlFileToJson.mockResolvedValueOnce({ ...header, ...alert })
-
-      // Act
-      const { added, deleted } = await metadataDiff.compare('file/path')
+      it('detects added elements', async () => {
+        // Arrange
+        mockedParseXmlFileToJson.mockResolvedValueOnce({ ...header, ...alert })
+        mockedParseXmlFileToJson.mockResolvedValueOnce({ ...header, ...wfBase })
 
-      // Assert
-      expect(added.size).toBe(0)
-      expect(deleted.get('WorkflowAlert')).toEqual(
-        new Set(['OtherTestEmailAlert', 'TestEmailAlert'])
-      )
-    })
+        // Act
+        const { added, deleted } = await metadataDiff.compare('file/path')
 
-    it('detects modified elements', async () => {
-      // Arrange
-      mockedParseXmlFileToJson.mockResolvedValueOnce({
-        ...header,
-        ...alertTest,
-      })
-      mockedParseXmlFileToJson.mockResolvedValueOnce({
-        ...header,
-        ...alertTest,
-        Workflow: {
-          ...alertTest.Workflow,
-          alerts: { ...alertTest.Workflow.alerts, description: 'amazing' },
-        },
+        // Assert
+        expect(deleted.size).toBe(0)
+        expect(added.get('WorkflowAlert')).toEqual(
+          new Set(['OtherTestEmailAlert', 'TestEmailAlert'])
+        )
       })
+      it('detects removed elements', async () => {
+        // Arrange
+        mockedParseXmlFileToJson.mockResolvedValueOnce({ ...header, ...wfBase })
+        mockedParseXmlFileToJson.mockResolvedValueOnce({ ...header, ...alert })
 
-      // Act
-      const { added, deleted } = await metadataDiff.compare('file/path')
+        // Act
+        const { added, deleted } = await metadataDiff.compare('file/path')
 
-      // Assert
-      expect(deleted.size).toBe(0)
-      expect(added.get('WorkflowAlert')).toEqual(new Set(['TestEmailAlert']))
-    })
-  })
-  describe(`prune with ${JSON.stringify(header)} header`, () => {
-    it('given one element added, the generated file contains only this element', async () => {
-      // Arrange
-      mockedParseXmlFileToJson.mockResolvedValueOnce({ ...header, ...alert })
-      mockedParseXmlFileToJson.mockResolvedValueOnce({
-        ...header,
-        ...alertTest,
+        // Assert
+        expect(added.size).toBe(0)
+        expect(deleted.get('WorkflowAlert')).toEqual(
+          new Set(['OtherTestEmailAlert', 'TestEmailAlert'])
+        )
       })
-      await metadataDiff.compare('file/path')
 
-      // Act
-      const { isEmpty } = metadataDiff.prune()
+      it('detects parsed empty elements', async () => {
+        // Arrange
+        mockedParseXmlFileToJson.mockResolvedValueOnce({
+          ...header,
+          ...{
+            Workflow: {
+              '@_xmlns': 'http://soap.sforce.com/2006/04/metadata',
+              alerts: [],
+            },
+          },
+        })
+        mockedParseXmlFileToJson.mockResolvedValueOnce({ ...header, ...alert })
 
-      // Assert
-      expect(convertJsonToXml).toHaveBeenCalledWith({
-        ...header,
-        ...alertOther,
+        // Act
+        const { added, deleted } = await metadataDiff.compare('file/path')
+
+        // Assert
+        expect(added.size).toBe(0)
+        expect(deleted.get('WorkflowAlert')).toEqual(
+          new Set(['OtherTestEmailAlert', 'TestEmailAlert'])
+        )
       })
-      expect(isEmpty).toBe(false)
-    })
-    it('given every element deleted, the generated file is empty', async () => {
-      // Arrange
-      mockedParseXmlFileToJson.mockResolvedValueOnce({ ...header, ...wfBase })
-      mockedParseXmlFileToJson.mockResolvedValueOnce({ ...header, ...alert })
-      await metadataDiff.compare('file/path')
 
-      // Act
-      const { isEmpty } = metadataDiff.prune()
+      it('detects deleted file', async () => {
+        // Arrange
+        mockedParseXmlFileToJson.mockResolvedValueOnce('')
+        mockedParseXmlFileToJson.mockResolvedValueOnce({ ...header, ...alert })
 
-      // Assert
-      expect(convertJsonToXml).toHaveBeenCalledWith({ ...header, ...wfBase })
-      expect(isEmpty).toBe(true)
-    })
+        // Act
+        const { added, deleted } = await metadataDiff.compare('file/path')
 
-    it('given only elements removed and empty parsed, the generated file is empty', async () => {
-      // Arrange
-      mockedParseXmlFileToJson.mockResolvedValueOnce({
-        ...header,
-        ...{
-          Profile: {
-            '@_xmlns': 'http://soap.sforce.com/2006/04/metadata',
-            layoutAssignments: [],
-          },
-        },
+        // Assert
+        expect(added.size).toBe(0)
+        expect(deleted.get('WorkflowAlert')).toEqual(
+          new Set(['OtherTestEmailAlert', 'TestEmailAlert'])
+        )
       })
-      mockedParseXmlFileToJson.mockResolvedValueOnce({
-        ...header,
-        ...{
-          Profile: {
-            '@_xmlns': 'http://soap.sforce.com/2006/04/metadata',
-            layoutAssignments: [
-              {
-                layout: 'another-test-layout',
-                recordType: 'test-recordType',
-              },
-            ],
+
+      it('detects modified elements', async () => {
+        // Arrange
+        mockedParseXmlFileToJson.mockResolvedValueOnce({
+          ...header,
+          ...alertTest,
+        })
+        mockedParseXmlFileToJson.mockResolvedValueOnce({
+          ...header,
+          ...alertTest,
+          Workflow: {
+            ...alertTest.Workflow,
+            alerts: { ...alertTest.Workflow.alerts, description: 'amazing' },
           },
-        },
-      })
-      await metadataDiff.compare('file/path')
+        })
 
-      // Act
-      const { isEmpty } = metadataDiff.prune()
+        // Act
+        const { added, deleted } = await metadataDiff.compare('file/path')
 
-      // Assert
-      expect(convertJsonToXml).toHaveBeenCalledWith({
-        ...header,
-        ...{
-          Profile: {
-            '@_xmlns': 'http://soap.sforce.com/2006/04/metadata',
-          },
-        },
+        // Assert
+        expect(deleted.size).toBe(0)
+        expect(added.get('WorkflowAlert')).toEqual(new Set(['TestEmailAlert']))
       })
-      expect(isEmpty).toBe(true)
     })
-    it('given file contains only new element, it keeps the file identical', async () => {
-      // Arrange
-      mockedParseXmlFileToJson.mockResolvedValueOnce({ ...header, ...alert })
-      mockedParseXmlFileToJson.mockResolvedValueOnce({ ...header, ...wfBase })
-      await metadataDiff.compare('file/path')
-
-      // Act
-      const { isEmpty } = metadataDiff.prune()
+    describe(`prune with ${JSON.stringify(header)} header`, () => {
+      it('given one element added, the generated file contains only this element', async () => {
+        // Arrange
+        mockedParseXmlFileToJson.mockResolvedValueOnce({ ...header, ...alert })
+        mockedParseXmlFileToJson.mockResolvedValueOnce({
+          ...header,
+          ...alertTest,
+        })
+        await metadataDiff.compare('file/path')
 
-      // Assert
-      expect(convertJsonToXml).toHaveBeenCalledWith({ ...header, ...alert })
-      expect(isEmpty).toBe(false)
-    })
+        // Act
+        const { isEmpty } = metadataDiff.prune()
 
-    it('given one element modified, the generated file contains only this element', async () => {
-      // Arrange
-      mockedParseXmlFileToJson.mockResolvedValueOnce({
-        ...header,
-        ...alertOther,
-      })
-      mockedParseXmlFileToJson.mockResolvedValueOnce({
-        ...header,
-        ...alertOther,
-        Workflow: {
-          ...alertOther.Workflow,
-          alerts: { ...alertOther.Workflow.alerts, description: 'amazing' },
-        },
+        // Assert
+        expect(convertJsonToXml).toHaveBeenCalledWith({
+          ...header,
+          ...alertOther,
+        })
+        expect(isEmpty).toBe(false)
       })
-      await metadataDiff.compare('file/path')
+      it('given every element deleted, the generated file is empty', async () => {
+        // Arrange
+        mockedParseXmlFileToJson.mockResolvedValueOnce({ ...header, ...wfBase })
+        mockedParseXmlFileToJson.mockResolvedValueOnce({ ...header, ...alert })
+        await metadataDiff.compare('file/path')
 
-      // Act
-      const { isEmpty } = metadataDiff.prune()
+        // Act
+        const { isEmpty } = metadataDiff.prune()
 
-      // Assert
-      expect(convertJsonToXml).toHaveBeenCalledWith({
-        ...header,
-        ...alertOther,
+        // Assert
+        expect(convertJsonToXml).toHaveBeenCalledWith({ ...header, ...wfBase })
+        expect(isEmpty).toBe(true)
       })
-      expect(isEmpty).toBe(false)
-    })
 
-    describe('key less elements', () => {
-      it('given one element modified, the generated file contains the difference', async () => {
-        /*
-        Cas loginHours et loginIpRanges = si les tableaux sont égaux => on met tableau vide sinon on met le dernier tableau
-        Cas layout : ajouter tous les éléments de to qui ne sont pas dans from
-        */
+      it('given only elements removed and empty parsed, the generated file is empty', async () => {
         // Arrange
         mockedParseXmlFileToJson.mockResolvedValueOnce({
           ...header,
-          ...profileChanged,
+          ...{
+            Profile: {
+              '@_xmlns': 'http://soap.sforce.com/2006/04/metadata',
+              layoutAssignments: [],
+            },
+          },
         })
         mockedParseXmlFileToJson.mockResolvedValueOnce({
           ...header,
-          ...profile,
+          ...{
+            Profile: {
+              '@_xmlns': 'http://soap.sforce.com/2006/04/metadata',
+              layoutAssignments: [
+                {
+                  layout: 'another-test-layout',
+                  recordType: 'test-recordType',
+                },
+              ],
+            },
+          },
         })
         await metadataDiff.compare('file/path')
 
@@ -471,20 +399,41 @@ describe.each([[{}], [xmlHeader]])(`MetadataDiff`, header => {
         // Assert
         expect(convertJsonToXml).toHaveBeenCalledWith({
           ...header,
-          ...profileChanged,
+          ...{
+            Profile: {
+              '@_xmlns': 'http://soap.sforce.com/2006/04/metadata',
+            },
+          },
         })
+        expect(isEmpty).toBe(true)
+      })
+      it('given file contains only new element, it keeps the file identical', async () => {
+        // Arrange
+        mockedParseXmlFileToJson.mockResolvedValueOnce({ ...header, ...alert })
+        mockedParseXmlFileToJson.mockResolvedValueOnce({ ...header, ...wfBase })
+        await metadataDiff.compare('file/path')
+
+        // Act
+        const { isEmpty } = metadataDiff.prune()
+
+        // Assert
+        expect(convertJsonToXml).toHaveBeenCalledWith({ ...header, ...alert })
         expect(isEmpty).toBe(false)
       })
 
-      it('given added elements, the generated file contains the difference', async () => {
+      it('given one element modified, the generated file contains only this element', async () => {
         // Arrange
         mockedParseXmlFileToJson.mockResolvedValueOnce({
           ...header,
-          ...profileAdded,
+          ...alertOther,
         })
         mockedParseXmlFileToJson.mockResolvedValueOnce({
           ...header,
-          ...profile,
+          ...alertOther,
+          Workflow: {
+            ...alertOther.Workflow,
+            alerts: { ...alertOther.Workflow.alerts, description: 'amazing' },
+          },
         })
         await metadataDiff.compare('file/path')
 
@@ -494,53 +443,148 @@ describe.each([[{}], [xmlHeader]])(`MetadataDiff`, header => {
         // Assert
         expect(convertJsonToXml).toHaveBeenCalledWith({
           ...header,
-          ...{
-            Profile: {
-              '@_xmlns': 'http://soap.sforce.com/2006/04/metadata',
-              layoutAssignments: [
-                {
-                  layout: 'another-test-layout',
-                  recordType: 'test-recordType',
-                },
-              ],
-              loginHours: [
-                {
-                  mondayStart: '300',
-                  mondayEnd: '500',
-                },
-                {
-                  tuesdayStart: '400',
-                  tuesdayEnd: '500',
-                },
-              ],
-              loginIpRanges: [
-                {
-                  description: 'ip range description',
-                  endAddress: '168.0.0.0',
-                  startAddress: '168.0.0.255',
-                },
-                {
-                  description: 'complete ip range description',
-                  endAddress: '168.0.0.1',
-                  startAddress: '168.0.0.255',
-                },
-              ],
-            },
-          },
+          ...alertOther,
         })
         expect(isEmpty).toBe(false)
       })
 
-      it('given no element added nor modified, the generated file contains empty definition', async () => {
-        // Arrange
-        mockedParseXmlFileToJson.mockResolvedValueOnce({
-          ...header,
-          ...profile,
+      describe('key less elements', () => {
+        it('given one element modified, the generated file contains the difference', async () => {
+          /*
+        Cas loginHours et loginIpRanges = si les tableaux sont égaux => on met tableau vide sinon on met le dernier tableau
+        Cas layout : ajouter tous les éléments de to qui ne sont pas dans from
+        */
+          // Arrange
+          mockedParseXmlFileToJson.mockResolvedValueOnce({
+            ...header,
+            ...profileChanged,
+          })
+          mockedParseXmlFileToJson.mockResolvedValueOnce({
+            ...header,
+            ...profile,
+          })
+          await metadataDiff.compare('file/path')
+
+          // Act
+          const { isEmpty } = metadataDiff.prune()
+
+          // Assert
+          expect(convertJsonToXml).toHaveBeenCalledWith({
+            ...header,
+            ...profileChanged,
+          })
+          expect(isEmpty).toBe(false)
+        })
+
+        it('given added elements, the generated file contains the difference', async () => {
+          // Arrange
+          mockedParseXmlFileToJson.mockResolvedValueOnce({
+            ...header,
+            ...profileAdded,
+          })
+          mockedParseXmlFileToJson.mockResolvedValueOnce({
+            ...header,
+            ...profile,
+          })
+          await metadataDiff.compare('file/path')
+
+          // Act
+          const { isEmpty } = metadataDiff.prune()
+
+          // Assert
+          expect(convertJsonToXml).toHaveBeenCalledWith({
+            ...header,
+            ...{
+              Profile: {
+                '@_xmlns': 'http://soap.sforce.com/2006/04/metadata',
+                layoutAssignments: [
+                  {
+                    layout: 'another-test-layout',
+                    recordType: 'test-recordType',
+                  },
+                ],
+                loginHours: [
+                  {
+                    mondayStart: '300',
+                    mondayEnd: '500',
+                  },
+                  {
+                    tuesdayStart: '400',
+                    tuesdayEnd: '500',
+                  },
+                ],
+                loginIpRanges: [
+                  {
+                    description: 'ip range description',
+                    endAddress: '168.0.0.0',
+                    startAddress: '168.0.0.255',
+                  },
+                  {
+                    description: 'complete ip range description',
+                    endAddress: '168.0.0.1',
+                    startAddress: '168.0.0.255',
+                  },
+                ],
+              },
+            },
+          })
+          expect(isEmpty).toBe(false)
+        })
+
+        it('given no element added nor modified, the generated file contains empty definition', async () => {
+          // Arrange
+          mockedParseXmlFileToJson.mockResolvedValueOnce({
+            ...header,
+            ...profile,
+          })
+          mockedParseXmlFileToJson.mockResolvedValueOnce({
+            ...header,
+            ...profile,
+          })
+          await metadataDiff.compare('file/path')
+
+          // Act
+          const { isEmpty } = metadataDiff.prune()
+
+          // Assert
+          expect(convertJsonToXml).toHaveBeenCalledWith({
+            ...header,
+            ...emptyProfile,
+          })
+          expect(isEmpty).toBe(true)
+        })
+
+        it('given no element added nor modified, the generated file contains empty profile', async () => {
+          // Arrange
+          mockedParseXmlFileToJson.mockResolvedValueOnce({
+            ...header,
+            ...emptyProfile,
+          })
+          mockedParseXmlFileToJson.mockResolvedValueOnce({
+            ...header,
+            ...profile,
+          })
+          await metadataDiff.compare('file/path')
+
+          // Act
+          const { isEmpty } = metadataDiff.prune()
+
+          // Assert
+          expect(convertJsonToXml).toHaveBeenCalledWith({
+            ...header,
+            ...emptyProfile,
+          })
+          expect(isEmpty).toBe(true)
         })
+      })
+
+      it('given untracked element added and empty base, nothing trackable changed, the generated file is not empty', async () => {
+        // Arrange
         mockedParseXmlFileToJson.mockResolvedValueOnce({
           ...header,
-          ...profile,
+          ...unTracked,
         })
+        mockedParseXmlFileToJson.mockResolvedValueOnce({ ...header, ...wfBase })
         await metadataDiff.compare('file/path')
 
         // Act
@@ -549,20 +593,29 @@ describe.each([[{}], [xmlHeader]])(`MetadataDiff`, header => {
         // Assert
         expect(convertJsonToXml).toHaveBeenCalledWith({
           ...header,
-          ...emptyProfile,
+          ...unTracked,
         })
-        expect(isEmpty).toBe(true)
+        expect(isEmpty).toBe(false)
       })
 
-      it('given no element added nor modified, the generated file contains empty profile', async () => {
+      it('given untracked element added and other base, nothing trackable changed, the generated file is not empty', async () => {
         // Arrange
         mockedParseXmlFileToJson.mockResolvedValueOnce({
           ...header,
-          ...emptyProfile,
+          ...unTracked,
         })
         mockedParseXmlFileToJson.mockResolvedValueOnce({
           ...header,
-          ...profile,
+          ...{
+            Workflow: {
+              '@_xmlns': 'http://soap.sforce.com/2006/04/metadata',
+              unTracked: [
+                {
+                  fullName: 'default',
+                },
+              ],
+            },
+          },
         })
         await metadataDiff.compare('file/path')
 
@@ -572,56 +625,120 @@ describe.each([[{}], [xmlHeader]])(`MetadataDiff`, header => {
         // Assert
         expect(convertJsonToXml).toHaveBeenCalledWith({
           ...header,
-          ...emptyProfile,
+          ...unTracked,
         })
-        expect(isEmpty).toBe(true)
+        expect(isEmpty).toBe(false)
       })
     })
+  })
 
-    it('given untracked element added and empty base, nothing trackable changed, the generated file is not empty', async () => {
+  // Unskip me when checking for performance
+  describe.skip('Performance tests', () => {
+    const formatMemory = (bytes: number): string => {
+      if (bytes < 0) return '-' + formatMemory(-bytes)
+      if (bytes < 1024) return bytes + ' B'
+      if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB'
+      return (bytes / (1024 * 1024)).toFixed(2) + ' MB'
+    }
+
+    const makeWorkflow = (alerts: any[]) => {
+      return {
+        ...xmlHeader,
+        Workflow: {
+          '@_xmlns': 'http://soap.sforce.com/2006/04/metadata',
+          alerts,
+        },
+      }
+    }
+
+    const makeAlertWorkflow = (recordCount: number) => {
+      const alerts: any[] = []
+      for (let i = 0; i < recordCount; i++) {
+        alerts.push({
+          fullName: `TestEmailAlert${i}`,
+          description: `awesome${i}`,
+          protected: 'false',
+          recipients: { field: 'OtherEmail', type: 'email' },
+          senderAddress: 'awesome@awesome.com',
+          senderType: 'OrgWideEmailAddress',
+          template: 'None',
+        })
+      }
+
+      return makeWorkflow(alerts)
+    }
+
+    it('handles large workflows efficiently', async () => {
       // Arrange
-      mockedParseXmlFileToJson.mockResolvedValueOnce({
-        ...header,
-        ...unTracked,
-      })
-      mockedParseXmlFileToJson.mockResolvedValueOnce({ ...header, ...wfBase })
-      await metadataDiff.compare('file/path')
+      const baseWorkflow = makeAlertWorkflow(5000)
+      const modifiedWorkflow = JSON.parse(JSON.stringify(baseWorkflow))
+      const extraAlert = {
+        fullName: 'OtherTestEmailAlert',
+        description: 'awesome',
+        protected: 'false',
+        recipients: { field: 'OtherEmail', type: 'email' },
+        senderAddress: 'awesome@awesome.com',
+        senderType: 'OrgWideEmailAddress',
+        template: 'None',
+      }
+      modifiedWorkflow.Workflow.alerts.push(extraAlert)
+      mockedParseXmlFileToJson.mockResolvedValueOnce(modifiedWorkflow)
+      mockedParseXmlFileToJson.mockResolvedValueOnce(baseWorkflow)
+
+      if (global.gc) {
+        global.gc()
+      }
+
+      // Get initial memory usage
+      const initialMemory = process.memoryUsage()
+
+      // Track max memory usage during execution
+      let maxHeapUsed = initialMemory.heapUsed
+
+      // Setup memory sampling
+      const memoryCheckInterval = 5 // ms
+      const sampleMemoryUsage = () => {
+        const currentMemory = process.memoryUsage()
+        maxHeapUsed = Math.max(maxHeapUsed, currentMemory.heapUsed)
+      }
+
+      // Start sampling
+      const samplingInterval = setInterval(
+        sampleMemoryUsage,
+        memoryCheckInterval
+      )
 
       // Act
+      const startTime = process.hrtime.bigint()
+      await metadataDiff.compare('file/path')
       const { isEmpty } = metadataDiff.prune()
+      const endTime = process.hrtime.bigint()
 
-      // Assert
-      expect(convertJsonToXml).toHaveBeenCalledWith({ ...header, ...unTracked })
-      expect(isEmpty).toBe(false)
-    })
+      // Stop sampling
+      clearInterval(samplingInterval)
+      // Final check to capture any last peak
+      sampleMemoryUsage()
 
-    it('given untracked element added and other base, nothing trackable changed, the generated file is not empty', async () => {
-      // Arrange
-      mockedParseXmlFileToJson.mockResolvedValueOnce({
-        ...header,
-        ...unTracked,
-      })
-      mockedParseXmlFileToJson.mockResolvedValueOnce({
-        ...header,
-        ...{
-          Workflow: {
-            '@_xmlns': 'http://soap.sforce.com/2006/04/metadata',
-            unTracked: [
-              {
-                fullName: 'default',
-              },
-            ],
-          },
-        },
-      })
-      await metadataDiff.compare('file/path')
+      // Calculate duration
+      const duration = Number(endTime - startTime) / 1_000_000
 
-      // Act
-      const { isEmpty } = metadataDiff.prune()
+      // Log performance metrics
+      console.log(`Duration: ${duration.toFixed(2)}ms`)
+      console.log(
+        `Max Heap Used: ${formatMemory(maxHeapUsed - initialMemory.heapUsed)}`
+      )
 
       // Assert
-      expect(convertJsonToXml).toHaveBeenCalledWith({ ...header, ...unTracked })
       expect(isEmpty).toBe(false)
-    })
+
+      // Verify that the output contains only the extra alert
+      expect(convertJsonToXml).toHaveBeenCalledWith(
+        expect.objectContaining({
+          Workflow: expect.objectContaining({
+            alerts: expect.arrayContaining([extraAlert]),
+          }),
+        })
+      )
+    }, 20000)
   })
 })
diff --git a/knip.config.ts b/knip.config.ts
index bd22461ad..b118ef4a0 100644
--- a/knip.config.ts
+++ b/knip.config.ts
@@ -1,5 +1,4 @@
 export default {
-  packageManager: 'npm',
   entry: [
     'src/commands/sgd/source/delta.ts',
     'src/main.ts',
diff --git a/package-lock.json b/package-lock.json
index c0107a4c1..f683f389e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,11 +10,11 @@
       "license": "MIT",
       "dependencies": {
         "@oclif/core": "^4.2.10",
-        "@salesforce/core": "^8.8.7",
+        "@salesforce/core": "^8.9.0",
         "@salesforce/sf-plugins-core": "^12.2.1",
         "async": "^3.2.6",
         "fast-equals": "^5.2.2",
-        "fast-xml-parser": "^5.0.9",
+        "fast-xml-parser": "^5.2.0",
         "fs-extra": "^11.3.0",
         "ignore": "^7.0.3",
         "lodash-es": "^4.17.21",
@@ -40,26 +40,26 @@
         "@types/lodash": "^4.17.16",
         "@types/lodash-es": "^4.17.12",
         "@types/mocha": "^10.0.10",
-        "@types/node": "^22.13.17",
+        "@types/node": "^22.14.1",
         "benchmark": "^2.1.4",
         "chai": "^5.2.0",
         "husky": "^9.1.7",
         "jest": "^29.7.0",
-        "knip": "^5.46.4",
-        "lint-staged": "^15.5.0",
+        "knip": "^5.50.2",
+        "lint-staged": "^15.5.1",
         "lodash": "^4.17.21",
         "mocha": "^11.1.0",
         "nyc": "^17.1.0",
-        "oclif": "^4.17.42",
+        "oclif": "^4.17.43",
         "shx": "^0.4.0",
         "sinon": "^20.0.0",
         "ts-jest": "^29.3.1",
         "ts-jest-mock-import-meta": "^1.3.0",
         "ts-node": "^10.9.2",
         "tslib": "^2.8.1",
-        "typescript": "^5.8.2",
+        "typescript": "^5.8.3",
         "validate-branch-name": "^1.3.2",
-        "wireit": "^0.14.11"
+        "wireit": "^0.14.12"
       },
       "engines": {
         "node": ">=18.6.0"
@@ -393,9 +393,9 @@
       }
     },
     "node_modules/@aws-sdk/client-s3": {
-      "version": "3.779.0",
-      "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.779.0.tgz",
-      "integrity": "sha512-Lagz+ersQaLNYkpOU9V12PYspT//lGvhPXlKU3OXDj3whDchdqUdtRKY8rmV+jli4KXe+udx/hj2yqrFRfKGvQ==",
+      "version": "3.782.0",
+      "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.782.0.tgz",
+      "integrity": "sha512-V6JR2JAGYQY7J8wk5un5n/ja2nfCUyyoRCF8Du8JL91NGI8i41Mdr/TzuOGwTgFl6RSXb/ge1K1jk30OH4MugQ==",
       "dev": true,
       "license": "Apache-2.0",
       "dependencies": {
@@ -403,7 +403,7 @@
         "@aws-crypto/sha256-browser": "5.2.0",
         "@aws-crypto/sha256-js": "5.2.0",
         "@aws-sdk/core": "3.775.0",
-        "@aws-sdk/credential-provider-node": "3.777.0",
+        "@aws-sdk/credential-provider-node": "3.782.0",
         "@aws-sdk/middleware-bucket-endpoint": "3.775.0",
         "@aws-sdk/middleware-expect-continue": "3.775.0",
         "@aws-sdk/middleware-flexible-checksums": "3.775.0",
@@ -413,13 +413,13 @@
         "@aws-sdk/middleware-recursion-detection": "3.775.0",
         "@aws-sdk/middleware-sdk-s3": "3.775.0",
         "@aws-sdk/middleware-ssec": "3.775.0",
-        "@aws-sdk/middleware-user-agent": "3.775.0",
+        "@aws-sdk/middleware-user-agent": "3.782.0",
         "@aws-sdk/region-config-resolver": "3.775.0",
         "@aws-sdk/signature-v4-multi-region": "3.775.0",
         "@aws-sdk/types": "3.775.0",
-        "@aws-sdk/util-endpoints": "3.775.0",
+        "@aws-sdk/util-endpoints": "3.782.0",
         "@aws-sdk/util-user-agent-browser": "3.775.0",
-        "@aws-sdk/util-user-agent-node": "3.775.0",
+        "@aws-sdk/util-user-agent-node": "3.782.0",
         "@aws-sdk/xml-builder": "3.775.0",
         "@smithy/config-resolver": "^4.1.0",
         "@smithy/core": "^3.2.0",
@@ -461,9 +461,9 @@
       }
     },
     "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/client-sso": {
-      "version": "3.777.0",
-      "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.777.0.tgz",
-      "integrity": "sha512-0+z6CiAYIQa7s6FJ+dpBYPi9zr9yY5jBg/4/FGcwYbmqWPXwL9Thdtr0FearYRZgKl7bhL3m3dILCCfWqr3teQ==",
+      "version": "3.782.0",
+      "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.782.0.tgz",
+      "integrity": "sha512-5GlJBejo8wqMpSSEKb45WE82YxI2k73YuebjLH/eWDNQeE6VI5Bh9lA1YQ7xNkLLH8hIsb0pSfKVuwh0VEzVrg==",
       "dev": true,
       "license": "Apache-2.0",
       "dependencies": {
@@ -473,12 +473,12 @@
         "@aws-sdk/middleware-host-header": "3.775.0",
         "@aws-sdk/middleware-logger": "3.775.0",
         "@aws-sdk/middleware-recursion-detection": "3.775.0",
-        "@aws-sdk/middleware-user-agent": "3.775.0",
+        "@aws-sdk/middleware-user-agent": "3.782.0",
         "@aws-sdk/region-config-resolver": "3.775.0",
         "@aws-sdk/types": "3.775.0",
-        "@aws-sdk/util-endpoints": "3.775.0",
+        "@aws-sdk/util-endpoints": "3.782.0",
         "@aws-sdk/util-user-agent-browser": "3.775.0",
-        "@aws-sdk/util-user-agent-node": "3.775.0",
+        "@aws-sdk/util-user-agent-node": "3.782.0",
         "@smithy/config-resolver": "^4.1.0",
         "@smithy/core": "^3.2.0",
         "@smithy/fetch-http-handler": "^5.0.2",
@@ -573,9 +573,9 @@
       }
     },
     "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/credential-provider-ini": {
-      "version": "3.777.0",
-      "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.777.0.tgz",
-      "integrity": "sha512-1X9mCuM9JSQPmQ+D2TODt4THy6aJWCNiURkmKmTIPRdno7EIKgAqrr/LLN++K5mBf54DZVKpqcJutXU2jwo01A==",
+      "version": "3.782.0",
+      "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.782.0.tgz",
+      "integrity": "sha512-wd4KdRy2YjLsE4Y7pz00470Iip06GlRHkG4dyLW7/hFMzEO2o7ixswCWp6J2VGZVAX64acknlv2Q0z02ebjmhw==",
       "dev": true,
       "license": "Apache-2.0",
       "dependencies": {
@@ -583,9 +583,9 @@
         "@aws-sdk/credential-provider-env": "3.775.0",
         "@aws-sdk/credential-provider-http": "3.775.0",
         "@aws-sdk/credential-provider-process": "3.775.0",
-        "@aws-sdk/credential-provider-sso": "3.777.0",
-        "@aws-sdk/credential-provider-web-identity": "3.777.0",
-        "@aws-sdk/nested-clients": "3.777.0",
+        "@aws-sdk/credential-provider-sso": "3.782.0",
+        "@aws-sdk/credential-provider-web-identity": "3.782.0",
+        "@aws-sdk/nested-clients": "3.782.0",
         "@aws-sdk/types": "3.775.0",
         "@smithy/credential-provider-imds": "^4.0.2",
         "@smithy/property-provider": "^4.0.2",
@@ -598,18 +598,18 @@
       }
     },
     "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/credential-provider-node": {
-      "version": "3.777.0",
-      "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.777.0.tgz",
-      "integrity": "sha512-ZD66ywx1Q0KyUSuBXZIQzBe3Q7MzX8lNwsrCU43H3Fww+Y+HB3Ncws9grhSdNhKQNeGmZ+MgKybuZYaaeLwJEQ==",
+      "version": "3.782.0",
+      "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.782.0.tgz",
+      "integrity": "sha512-HZiAF+TCEyKjju9dgysjiPIWgt/+VerGaeEp18mvKLNfgKz1d+/82A2USEpNKTze7v3cMFASx3CvL8yYyF7mJw==",
       "dev": true,
       "license": "Apache-2.0",
       "dependencies": {
         "@aws-sdk/credential-provider-env": "3.775.0",
         "@aws-sdk/credential-provider-http": "3.775.0",
-        "@aws-sdk/credential-provider-ini": "3.777.0",
+        "@aws-sdk/credential-provider-ini": "3.782.0",
         "@aws-sdk/credential-provider-process": "3.775.0",
-        "@aws-sdk/credential-provider-sso": "3.777.0",
-        "@aws-sdk/credential-provider-web-identity": "3.777.0",
+        "@aws-sdk/credential-provider-sso": "3.782.0",
+        "@aws-sdk/credential-provider-web-identity": "3.782.0",
         "@aws-sdk/types": "3.775.0",
         "@smithy/credential-provider-imds": "^4.0.2",
         "@smithy/property-provider": "^4.0.2",
@@ -640,15 +640,15 @@
       }
     },
     "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/credential-provider-sso": {
-      "version": "3.777.0",
-      "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.777.0.tgz",
-      "integrity": "sha512-9mPz7vk9uE4PBVprfINv4tlTkyq1OonNevx2DiXC1LY4mCUCNN3RdBwAY0BTLzj0uyc3k5KxFFNbn3/8ZDQP7w==",
+      "version": "3.782.0",
+      "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.782.0.tgz",
+      "integrity": "sha512-1y1ucxTtTIGDSNSNxriQY8msinilhe9gGvQpUDYW9gboyC7WQJPDw66imy258V6osdtdi+xoHzVCbCz3WhosMQ==",
       "dev": true,
       "license": "Apache-2.0",
       "dependencies": {
-        "@aws-sdk/client-sso": "3.777.0",
+        "@aws-sdk/client-sso": "3.782.0",
         "@aws-sdk/core": "3.775.0",
-        "@aws-sdk/token-providers": "3.777.0",
+        "@aws-sdk/token-providers": "3.782.0",
         "@aws-sdk/types": "3.775.0",
         "@smithy/property-provider": "^4.0.2",
         "@smithy/shared-ini-file-loader": "^4.0.2",
@@ -660,14 +660,14 @@
       }
     },
     "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/credential-provider-web-identity": {
-      "version": "3.777.0",
-      "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.777.0.tgz",
-      "integrity": "sha512-uGCqr47fnthkqwq5luNl2dksgcpHHjSXz2jUra7TXtFOpqvnhOW8qXjoa1ivlkq8qhqlaZwCzPdbcN0lXpmLzQ==",
+      "version": "3.782.0",
+      "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.782.0.tgz",
+      "integrity": "sha512-xCna0opVPaueEbJoclj5C6OpDNi0Gynj+4d7tnuXGgQhTHPyAz8ZyClkVqpi5qvHTgxROdUEDxWqEO5jqRHZHQ==",
       "dev": true,
       "license": "Apache-2.0",
       "dependencies": {
         "@aws-sdk/core": "3.775.0",
-        "@aws-sdk/nested-clients": "3.777.0",
+        "@aws-sdk/nested-clients": "3.782.0",
         "@aws-sdk/types": "3.775.0",
         "@smithy/property-provider": "^4.0.2",
         "@smithy/types": "^4.2.0",
@@ -725,15 +725,15 @@
       }
     },
     "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/middleware-user-agent": {
-      "version": "3.775.0",
-      "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.775.0.tgz",
-      "integrity": "sha512-7Lffpr1ptOEDE1ZYH1T78pheEY1YmeXWBfFt/amZ6AGsKSLG+JPXvof3ltporTGR2bhH/eJPo7UHCglIuXfzYg==",
+      "version": "3.782.0",
+      "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.782.0.tgz",
+      "integrity": "sha512-i32H2R6IItX+bQ2p4+v2gGO2jA80jQoJO2m1xjU9rYWQW3+ErWy4I5YIuQHTBfb6hSdAHbaRfqPDgbv9J2rjEg==",
       "dev": true,
       "license": "Apache-2.0",
       "dependencies": {
         "@aws-sdk/core": "3.775.0",
         "@aws-sdk/types": "3.775.0",
-        "@aws-sdk/util-endpoints": "3.775.0",
+        "@aws-sdk/util-endpoints": "3.782.0",
         "@smithy/core": "^3.2.0",
         "@smithy/protocol-http": "^5.1.0",
         "@smithy/types": "^4.2.0",
@@ -744,9 +744,9 @@
       }
     },
     "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/nested-clients": {
-      "version": "3.777.0",
-      "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.777.0.tgz",
-      "integrity": "sha512-bmmVRsCjuYlStYPt06hr+f8iEyWg7+AklKCA8ZLDEJujXhXIowgUIqXmqpTkXwkVvDQ9tzU7hxaONjyaQCGybA==",
+      "version": "3.782.0",
+      "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.782.0.tgz",
+      "integrity": "sha512-QOYC8q7luzHFXrP0xYAqBctoPkynjfV0r9dqntFu4/IWMTyC1vlo1UTxFAjIPyclYw92XJyEkVCVg9v/nQnsUA==",
       "dev": true,
       "license": "Apache-2.0",
       "dependencies": {
@@ -756,12 +756,12 @@
         "@aws-sdk/middleware-host-header": "3.775.0",
         "@aws-sdk/middleware-logger": "3.775.0",
         "@aws-sdk/middleware-recursion-detection": "3.775.0",
-        "@aws-sdk/middleware-user-agent": "3.775.0",
+        "@aws-sdk/middleware-user-agent": "3.782.0",
         "@aws-sdk/region-config-resolver": "3.775.0",
         "@aws-sdk/types": "3.775.0",
-        "@aws-sdk/util-endpoints": "3.775.0",
+        "@aws-sdk/util-endpoints": "3.782.0",
         "@aws-sdk/util-user-agent-browser": "3.775.0",
-        "@aws-sdk/util-user-agent-node": "3.775.0",
+        "@aws-sdk/util-user-agent-node": "3.782.0",
         "@smithy/config-resolver": "^4.1.0",
         "@smithy/core": "^3.2.0",
         "@smithy/fetch-http-handler": "^5.0.2",
@@ -812,13 +812,13 @@
       }
     },
     "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/token-providers": {
-      "version": "3.777.0",
-      "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.777.0.tgz",
-      "integrity": "sha512-Yc2cDONsHOa4dTSGOev6Ng2QgTKQUEjaUnsyKd13pc/nLLz/WLqHiQ/o7PcnKERJxXGs1g1C6l3sNXiX+kbnFQ==",
+      "version": "3.782.0",
+      "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.782.0.tgz",
+      "integrity": "sha512-4tPuk/3+THPrzKaXW4jE2R67UyGwHLFizZ47pcjJWbhb78IIJAy94vbeqEQ+veS84KF5TXcU7g5jGTXC0D70Wg==",
       "dev": true,
       "license": "Apache-2.0",
       "dependencies": {
-        "@aws-sdk/nested-clients": "3.777.0",
+        "@aws-sdk/nested-clients": "3.782.0",
         "@aws-sdk/types": "3.775.0",
         "@smithy/property-provider": "^4.0.2",
         "@smithy/shared-ini-file-loader": "^4.0.2",
@@ -844,9 +844,9 @@
       }
     },
     "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/util-endpoints": {
-      "version": "3.775.0",
-      "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.775.0.tgz",
-      "integrity": "sha512-yjWmUgZC9tUxAo8Uaplqmq0eUh0zrbZJdwxGRKdYxfm4RG6fMw1tj52+KkatH7o+mNZvg1GDcVp/INktxonJLw==",
+      "version": "3.782.0",
+      "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.782.0.tgz",
+      "integrity": "sha512-/RJOAO7o7HI6lEa4ASbFFLHGU9iPK876BhsVfnl54MvApPVYWQ9sHO0anOUim2S5lQTwd/6ghuH3rFYSq/+rdw==",
       "dev": true,
       "license": "Apache-2.0",
       "dependencies": {
@@ -873,13 +873,13 @@
       }
     },
     "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/util-user-agent-node": {
-      "version": "3.775.0",
-      "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.775.0.tgz",
-      "integrity": "sha512-N9yhTevbizTOMo3drH7Eoy6OkJ3iVPxhV7dwb6CMAObbLneS36CSfA6xQXupmHWcRvZPTz8rd1JGG3HzFOau+g==",
+      "version": "3.782.0",
+      "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.782.0.tgz",
+      "integrity": "sha512-dMFkUBgh2Bxuw8fYZQoH/u3H4afQ12VSkzEi//qFiDTwbKYq+u+RYjc8GLDM6JSK1BShMu5AVR7HD4ap1TYUnA==",
       "dev": true,
       "license": "Apache-2.0",
       "dependencies": {
-        "@aws-sdk/middleware-user-agent": "3.775.0",
+        "@aws-sdk/middleware-user-agent": "3.782.0",
         "@aws-sdk/types": "3.775.0",
         "@smithy/node-config-provider": "^4.0.2",
         "@smithy/types": "^4.2.0",
@@ -5024,9 +5024,9 @@
       }
     },
     "node_modules/@salesforce/core": {
-      "version": "8.8.7",
-      "resolved": "https://registry.npmjs.org/@salesforce/core/-/core-8.8.7.tgz",
-      "integrity": "sha512-AuPSmb/GZ7F8eV5GO6NcfFq3YSOVnPL4sssEQEvKduOSJMs8RTJ92kEefrId/tgFBYfw4b7UzndjFtdgrGGeoA==",
+      "version": "8.9.0",
+      "resolved": "https://registry.npmjs.org/@salesforce/core/-/core-8.9.0.tgz",
+      "integrity": "sha512-p3ihmhGRfMrbup9unQ+YjDQb0o7WiNPUg38KB9J11YRMd2zKvERE1ncQcsnfOQ1MLRxvDMUchlKNrAe5Xw7RZw==",
       "license": "BSD-3-Clause",
       "dependencies": {
         "@jsforce/jsforce-node": "^3.7.0",
@@ -6119,44 +6119,6 @@
         "node": ">=18.0.0"
       }
     },
-    "node_modules/@snyk/github-codeowners": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/@snyk/github-codeowners/-/github-codeowners-1.1.0.tgz",
-      "integrity": "sha512-lGFf08pbkEac0NYgVf4hdANpAgApRjNByLXB+WBip3qj1iendOIyAwP2GKkKbQMNVy2r1xxDf0ssfWscoiC+Vw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "commander": "^4.1.1",
-        "ignore": "^5.1.8",
-        "p-map": "^4.0.0"
-      },
-      "bin": {
-        "github-codeowners": "dist/cli.js"
-      },
-      "engines": {
-        "node": ">=8.10"
-      }
-    },
-    "node_modules/@snyk/github-codeowners/node_modules/commander": {
-      "version": "4.1.1",
-      "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
-      "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">= 6"
-      }
-    },
-    "node_modules/@snyk/github-codeowners/node_modules/ignore": {
-      "version": "5.3.2",
-      "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
-      "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">= 4"
-      }
-    },
     "node_modules/@stryker-mutator/api": {
       "version": "8.7.1",
       "resolved": "https://registry.npmjs.org/@stryker-mutator/api/-/api-8.7.1.tgz",
@@ -6609,12 +6571,12 @@
       }
     },
     "node_modules/@types/node": {
-      "version": "22.13.17",
-      "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.17.tgz",
-      "integrity": "sha512-nAJuQXoyPj04uLgu+obZcSmsfOenUg6DxPKogeUy6yNCFwWaj5sBF8/G/pNo8EtBJjAfSVgfIlugR/BCOleO+g==",
+      "version": "22.14.1",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz",
+      "integrity": "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==",
       "license": "MIT",
       "dependencies": {
-        "undici-types": "~6.20.0"
+        "undici-types": "~6.21.0"
       }
     },
     "node_modules/@types/prop-types": {
@@ -8885,9 +8847,9 @@
       "license": "MIT"
     },
     "node_modules/fast-xml-parser": {
-      "version": "5.0.9",
-      "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.0.9.tgz",
-      "integrity": "sha512-2mBwCiuW3ycKQQ6SOesSB8WeF+fIGb6I/GG5vU5/XEptwFFhp9PE8b9O7fbs2dpq9fXn4ULR3UsfydNUCntf5A==",
+      "version": "5.2.0",
+      "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.0.tgz",
+      "integrity": "sha512-Uw9+Mjt4SBRud1IcaYuW/O0lW8SKKdMl5g7g24HiIuyH5fQSD+AVLybSlJtqLYEbytVFjWQa5DMGcNgeksdRBg==",
       "funding": [
         {
           "type": "github",
@@ -12274,9 +12236,9 @@
       }
     },
     "node_modules/knip": {
-      "version": "5.46.4",
-      "resolved": "https://registry.npmjs.org/knip/-/knip-5.46.4.tgz",
-      "integrity": "sha512-iU2VGdXOPOj6Y8jEeixYMjlf2MCLZNjB63u2pfuP14gprRFjxgF+8wZiCgrjvogWt9H2WT+ytLYouXoEFAcm5g==",
+      "version": "5.50.2",
+      "resolved": "https://registry.npmjs.org/knip/-/knip-5.50.2.tgz",
+      "integrity": "sha512-TGpfeeSMlaRd5wUkcb4HsVGSiQrE289LZF9qtW2TLHkAZbB2rM53wVQbXSf1KjOvJfBSZYSyYQ6q79lufrwsPw==",
       "dev": true,
       "funding": [
         {
@@ -12294,8 +12256,7 @@
       ],
       "license": "ISC",
       "dependencies": {
-        "@nodelib/fs.walk": "3.0.1",
-        "@snyk/github-codeowners": "1.1.0",
+        "@nodelib/fs.walk": "^1.2.3",
         "easy-table": "1.2.0",
         "enhanced-resolve": "^5.18.1",
         "fast-glob": "^3.3.3",
@@ -12307,7 +12268,6 @@
         "pretty-ms": "^9.0.0",
         "smol-toml": "^1.3.1",
         "strip-json-comments": "5.0.1",
-        "summary": "2.1.0",
         "zod": "^3.22.4",
         "zod-validation-error": "^3.0.3"
       },
@@ -12323,44 +12283,6 @@
         "typescript": ">=5.0.4"
       }
     },
-    "node_modules/knip/node_modules/@nodelib/fs.scandir": {
-      "version": "4.0.1",
-      "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-4.0.1.tgz",
-      "integrity": "sha512-vAkI715yhnmiPupY+dq+xenu5Tdf2TBQ66jLvBIcCddtz+5Q8LbMKaf9CIJJreez8fQ8fgaY+RaywQx8RJIWpw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@nodelib/fs.stat": "4.0.0",
-        "run-parallel": "^1.2.0"
-      },
-      "engines": {
-        "node": ">=18.18.0"
-      }
-    },
-    "node_modules/knip/node_modules/@nodelib/fs.stat": {
-      "version": "4.0.0",
-      "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-4.0.0.tgz",
-      "integrity": "sha512-ctr6bByzksKRCV0bavi8WoQevU6plSp2IkllIsEqaiKe2mwNNnaluhnRhcsgGZHrrHk57B3lf95MkLMO3STYcg==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=18.18.0"
-      }
-    },
-    "node_modules/knip/node_modules/@nodelib/fs.walk": {
-      "version": "3.0.1",
-      "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-3.0.1.tgz",
-      "integrity": "sha512-nIh/M6Kh3ZtOmlY00DaUYB4xeeV6F3/ts1l29iwl3/cfyY/OuCfUx+v08zgx8TKPTifXRcjjqVQ4KB2zOYSbyw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@nodelib/fs.scandir": "4.0.1",
-        "fastq": "^1.15.0"
-      },
-      "engines": {
-        "node": ">=18.18.0"
-      }
-    },
     "node_modules/knip/node_modules/picomatch": {
       "version": "4.0.2",
       "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
@@ -12426,9 +12348,9 @@
       "license": "MIT"
     },
     "node_modules/lint-staged": {
-      "version": "15.5.0",
-      "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.5.0.tgz",
-      "integrity": "sha512-WyCzSbfYGhK7cU+UuDDkzUiytbfbi0ZdPy2orwtM75P3WTtQBzmG40cCxIa8Ii2+XjfxzLH6Be46tUfWS85Xfg==",
+      "version": "15.5.1",
+      "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.5.1.tgz",
+      "integrity": "sha512-6m7u8mue4Xn6wK6gZvSCQwBvMBR36xfY24nF5bMTf2MHDYG6S3yhJuOgdYVw99hsjyDt2d4z168b3naI8+NWtQ==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
@@ -13912,14 +13834,14 @@
       }
     },
     "node_modules/oclif": {
-      "version": "4.17.42",
-      "resolved": "https://registry.npmjs.org/oclif/-/oclif-4.17.42.tgz",
-      "integrity": "sha512-a20QxwQ9zqejl4Qgo8l7oTa0Z3oi04gpAohRySblzu7J6J+Wi3FY03yY9rAyY1GEIw8j0fQ4PC9K8CKsZoOt4Q==",
+      "version": "4.17.43",
+      "resolved": "https://registry.npmjs.org/oclif/-/oclif-4.17.43.tgz",
+      "integrity": "sha512-yvZX0XQtKtt6+4xKpl83TSY6c8k4AvnZne8u+I1up3eVaMQkt2hsGR+VWIoY0Jl0OG2WJG+6fNkgC0uKPtP8/g==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
         "@aws-sdk/client-cloudfront": "^3.764.0",
-        "@aws-sdk/client-s3": "^3.772.0",
+        "@aws-sdk/client-s3": "^3.782.0",
         "@inquirer/confirm": "^3.1.22",
         "@inquirer/input": "^2.2.4",
         "@inquirer/select": "^2.5.0",
@@ -14200,22 +14122,6 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
-    "node_modules/p-map": {
-      "version": "4.0.0",
-      "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz",
-      "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "aggregate-error": "^3.0.0"
-      },
-      "engines": {
-        "node": ">=10"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
     "node_modules/p-try": {
       "version": "2.2.0",
       "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
@@ -16068,13 +15974,6 @@
       ],
       "license": "MIT"
     },
-    "node_modules/summary": {
-      "version": "2.1.0",
-      "resolved": "https://registry.npmjs.org/summary/-/summary-2.1.0.tgz",
-      "integrity": "sha512-nMIjMrd5Z2nuB2RZCKJfFMjgS3fygbeyGk9PxPPaJR1RIcyN9yn4A63Isovzm3ZtQuEkLBVgMdPup8UeLH7aQw==",
-      "dev": true,
-      "license": "MIT"
-    },
     "node_modules/supports-color": {
       "version": "8.1.1",
       "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
@@ -16623,9 +16522,9 @@
       }
     },
     "node_modules/typescript": {
-      "version": "5.8.2",
-      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz",
-      "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==",
+      "version": "5.8.3",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
+      "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
       "dev": true,
       "license": "Apache-2.0",
       "bin": {
@@ -16644,9 +16543,9 @@
       "license": "MIT"
     },
     "node_modules/undici-types": {
-      "version": "6.20.0",
-      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
-      "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
+      "version": "6.21.0",
+      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+      "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
       "license": "MIT"
     },
     "node_modules/unicorn-magic": {
@@ -16934,9 +16833,9 @@
       }
     },
     "node_modules/wireit": {
-      "version": "0.14.11",
-      "resolved": "https://registry.npmjs.org/wireit/-/wireit-0.14.11.tgz",
-      "integrity": "sha512-edO3AiL1wYlBIapwx8e1OGEmsG37sv7qnRzx4YDsMPcKo+6NMOjWYcfRbaeoB1MzakLrnozWB2Q1q7Ko45NckA==",
+      "version": "0.14.12",
+      "resolved": "https://registry.npmjs.org/wireit/-/wireit-0.14.12.tgz",
+      "integrity": "sha512-gNSd+nZmMo6cuICezYXRIayu6TSOeCSCDzjSF0q6g8FKDsRbdqrONrSZYzdk/uBISmRcv4vZtsno6GyGvdXwGA==",
       "dev": true,
       "license": "Apache-2.0",
       "workspaces": [
diff --git a/package.json b/package.json
index 4e6356c6e..15bd74d1e 100644
--- a/package.json
+++ b/package.json
@@ -49,11 +49,11 @@
   },
   "dependencies": {
     "@oclif/core": "^4.2.10",
-    "@salesforce/core": "^8.8.7",
+    "@salesforce/core": "^8.9.0",
     "@salesforce/sf-plugins-core": "^12.2.1",
     "async": "^3.2.6",
     "fast-equals": "^5.2.2",
-    "fast-xml-parser": "^5.0.9",
+    "fast-xml-parser": "^5.2.0",
     "fs-extra": "^11.3.0",
     "ignore": "^7.0.3",
     "lodash-es": "^4.17.21",
@@ -79,26 +79,26 @@
     "@types/lodash": "^4.17.16",
     "@types/lodash-es": "^4.17.12",
     "@types/mocha": "^10.0.10",
-    "@types/node": "^22.13.17",
+    "@types/node": "^22.14.1",
     "benchmark": "^2.1.4",
     "chai": "^5.2.0",
     "husky": "^9.1.7",
     "jest": "^29.7.0",
-    "knip": "^5.46.4",
-    "lint-staged": "^15.5.0",
+    "knip": "^5.50.2",
+    "lint-staged": "^15.5.1",
     "lodash": "^4.17.21",
     "mocha": "^11.1.0",
     "nyc": "^17.1.0",
-    "oclif": "^4.17.42",
+    "oclif": "^4.17.43",
     "shx": "^0.4.0",
     "sinon": "^20.0.0",
     "ts-jest": "^29.3.1",
     "ts-jest-mock-import-meta": "^1.3.0",
     "ts-node": "^10.9.2",
     "tslib": "^2.8.1",
-    "typescript": "^5.8.2",
+    "typescript": "^5.8.3",
     "validate-branch-name": "^1.3.2",
-    "wireit": "^0.14.11"
+    "wireit": "^0.14.12"
   },
   "scripts": {
     "build": "wireit",
diff --git a/src/utils/metadataDiff.ts b/src/utils/metadataDiff.ts
index a102af28a..ba351e9a2 100644
--- a/src/utils/metadataDiff.ts
+++ b/src/utils/metadataDiff.ts
@@ -1,7 +1,6 @@
 'use strict'
-
 import { deepEqual } from 'fast-equals'
-import { differenceWith, isUndefined } from 'lodash-es'
+import { isUndefined } from 'lodash-es'
 
 import type { Config } from '../types/config.js'
 import type { SharedFileMetadata } from '../types/metadata.js'
@@ -16,6 +15,7 @@ import { ATTRIBUTE_PREFIX } from './fxpHelper.js'
 import { fillPackageWithParameter } from './packageHelper.js'
 
 const ARRAY_SPECIAL_KEY = '<array>'
+const OBJECT_SPECIAL_KEY = '<object>'
 
 const isEmpty = (arr: unknown[]) => arr.length === 0
 
@@ -256,13 +256,17 @@ class JsonTransformer {
       return toMeta
     }
 
+    let result: XmlContent[]
     if (isUndefined(keyField)) {
-      return this.getPartialContentWithoutKey(fromMeta, toMeta)
+      result = this.getPartialContentWithoutKey(fromMeta, toMeta)
     } else if (keyField === ARRAY_SPECIAL_KEY) {
-      return this.getPartialContentForArray(fromMeta, toMeta)
+      result = this.getPartialContentForArray(fromMeta, toMeta)
+    } else if (keyField === OBJECT_SPECIAL_KEY) {
+      result = this.getPartialContentForObject(fromMeta, toMeta)
     } else {
-      return this.getPartialContentWithKey(fromMeta, toMeta)
+      result = this.getPartialContentWithKey(fromMeta, toMeta, keyField)
     }
+    return result
   }
 
   private getPartialContentWithoutKey(
@@ -279,21 +283,43 @@ class JsonTransformer {
     fromMeta: XmlContent[],
     toMeta: XmlContent[]
   ): XmlContent[] {
-    if (!deepEqual(fromMeta, toMeta)) {
-      this.isEmpty = false
-      return toMeta
-    }
-    return []
+    const diff = deepEqual(fromMeta, toMeta) ? [] : toMeta
+    this.checkEmpty(diff)
+    return diff
   }
 
-  private getPartialContentWithKey(
+  private getPartialContentForObject(
     fromMeta: XmlContent[],
     toMeta: XmlContent[]
   ): XmlContent[] {
-    const diff = differenceWith(toMeta, fromMeta, deepEqual)
-    if (!isEmpty(diff)) {
-      this.isEmpty = false
-    }
+    const keySelector = (item: XmlContent[0]) => JSON.stringify(item)
+    const fromSet = new Set<string>(fromMeta.map(keySelector))
+    const diff = toMeta.filter(item => !fromSet.has(keySelector(item)))
+
+    this.checkEmpty(diff)
     return diff
   }
+
+  private getPartialContentWithKey(
+    fromMeta: XmlContent[],
+    toMeta: XmlContent[],
+    keyField: string
+  ): XmlContent[] {
+    const keySelector = (item: XmlContent[0]) => item[keyField]
+    const fromMap = new Map(fromMeta.map(item => [keySelector(item), item]))
+    const diff = toMeta.filter(item => {
+      const key = keySelector(item)
+      const fromItem = fromMap.get(key)
+      return isUndefined(fromItem) || !deepEqual(item, fromItem)
+    })
+
+    this.checkEmpty(diff)
+    return diff
+  }
+
+  private checkEmpty(diff: XmlContent[]) {
+    if (this.isEmpty) {
+      this.isEmpty = isEmpty(diff)
+    }
+  }
 }


Reviews:
user: login: scolladon
body: That's a very good start !! Great job, thanks for the contribution

I think we could also work with the `getPartialContent` method by adding a 4th check
````
	if (isUndefined(keyField)) {
      return this.getPartialContentWithoutKey(fromMeta, toMeta)
    } else if (keyField === ARRAY_SPECIAL_KEY) {
      return this.getPartialContentForArray(fromMeta, toMeta)
    } else if (keyField === OBJECT_SPECIAL_KEY) { // '<object>' => for layout assignment
      return this.getPartialContentForObject(fromMeta, toMeta) // this should be the actual getPartialContentWithKey method
    } else {
      return this.getPartialContentWithKey(fromMeta, toMeta, keyField) //. this could be a new method using the keyField to compare object 
    }
```
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-08T07:28:19Z
user: login: matdodgson
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T08:07:09Z
user: login: scolladon
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-08T08:19:14Z
user: login: scolladon
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-08T09:12:39Z
user: login: matdodgson
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T06:40:33Z
user: login: qlty-cloud-legacy[bot]
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-10T07:20:50Z
user: login: scolladon
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-10T07:37:55Z
user: login: scolladon
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-10T07:45:15Z
user: login: matdodgson
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T05:15:08Z
user: login: matdodgson
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T05:46:32Z
user: login: matdodgson
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T06:38:12Z
user: login: matdodgson
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T06:38:53Z
user: login: scolladon
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-11T12:41:11Z
user: login: scolladon
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-11T12:48:35Z
user: login: scolladon
state: COMMENTED
author_association: OWNER
submitted_at: 2025-04-11T15:21:32Z
user: login: matdodgson
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-12T05:12:09Z
user: login: scolladon
body: LGTM

Thank you very much @matdodgson for this very nice contribution.
It will make a lot of people happier 👏 🙏 ❤️ 
state: APPROVED
author_association: OWNER
submitted_at: 2025-04-12T08:13:30Z

Review comments:
diff_hunk: @@ -290,10 +290,24 @@ class JsonTransformer {
     fromMeta: XmlContent[],
     toMeta: XmlContent[]
   ): XmlContent[] {
-    const diff = differenceWith(toMeta, fromMeta, deepEqual)
+    // Build map of fromMeta elements by their stringified representation
+    const fromMap = new Map<string, XmlContent>()
+    for (const item of fromMeta) {
+      // Create a stable string key from the item
+      const key = JSON.stringify(item)
path: src/utils/metadataDiff.ts
user: login: scolladon
body: **suggestion:** the key generation could isolated in a method

This method could be a bit faster with a method like : `item.values().join('-')` instead of `JSON.stringify` ?
created_at: 2025-04-08T07:21:06Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,116 @@
+'use strict'
path: __tests__/unit/lib/utils/metadataDiffLarge.test.ts
user: login: scolladon
body: **praises:** nice touch to check the performance before pushing 👍 
created_at: 2025-04-08T07:23:36Z
author_association: OWNER
diff_hunk: @@ -290,10 +290,24 @@ class JsonTransformer {
     fromMeta: XmlContent[],
     toMeta: XmlContent[]
   ): XmlContent[] {
-    const diff = differenceWith(toMeta, fromMeta, deepEqual)
+    // Build map of fromMeta elements by their stringified representation
+    const fromMap = new Map<string, XmlContent>()
+    for (const item of fromMeta) {
+      // Create a stable string key from the item
+      const key = JSON.stringify(item)
path: src/utils/metadataDiff.ts
user: login: matdodgson
body: Please correct me if I'm wrong as I'm new to this codebase.

As I understand it, each item of fromMeta is an object. The goal of constructing the string key from the object and using that as the map key is to be able to efficiently check if an object exists by it's contents.

If using item.values().join('-') we would get only the values of the object, not it's keys, so we're missing an important part of the content check. This means there would be situations where we think an object matches but it doesn't because the keys are different. Additionally, we'd need our function to be recursive as an object can be hierarchical. 

objectToConcatenatedString is a function which recursively generates a string of the keys of an object. I asked Claude 3.7 to estimate the performance of JSON.stringify vs objectToConcatenatedString:

<img width="338" alt="key generation performance Screenshot 2025-04-08 175844" src="https://github.com/user-attachments/assets/7cb37aa9-52ee-485e-8e41-99e42cff39f9" />

Do you want me to look into this further - like trying some benchmarking?
created_at: 2025-04-08T08:07:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -290,10 +290,24 @@ class JsonTransformer {
     fromMeta: XmlContent[],
     toMeta: XmlContent[]
   ): XmlContent[] {
-    const diff = differenceWith(toMeta, fromMeta, deepEqual)
+    // Build map of fromMeta elements by their stringified representation
+    const fromMap = new Map<string, XmlContent>()
+    for (const item of fromMeta) {
+      // Create a stable string key from the item
+      const key = JSON.stringify(item)
path: src/utils/metadataDiff.ts
user: login: scolladon
body: Yep you're right, that's true when we are in the situation were we compare different objects.
But here we are comparing same objects definition, meaning they will always have the same keys.
And also we are comparing flat object, they are the leaf of the xml tree, so there is no need for recursion.

This is why, knowing that, we could put in place faster methods to identify them.
At first I thought about a sha1 method but in the end, just having the values is enough IMHO. If you could benchmark my hypothesis it would be very valuable for this comment
created_at: 2025-04-08T08:19:14Z
author_association: OWNER
diff_hunk: @@ -290,10 +290,24 @@ class JsonTransformer {
     fromMeta: XmlContent[],
     toMeta: XmlContent[]
   ): XmlContent[] {
-    const diff = differenceWith(toMeta, fromMeta, deepEqual)
+    // Build map of fromMeta elements by their stringified representation
+    const fromMap = new Map<string, XmlContent>()
+    for (const item of fromMeta) {
+      // Create a stable string key from the item
+      const key = JSON.stringify(item)
path: src/utils/metadataDiff.ts
user: login: scolladon
body: Maybe we could go with a Set for further optimization with the Heap and the constructors ? (In addition to the split of function proposed)

```suggestion
	const genKey = item => item.values().join('-')

      // Build set of stringified fromMeta elements
    const fromSet = new Set<string>(fromMeta.map(genKey));

    // Filter toMeta to only include items not in fromSet
    const diff = toMeta.filter(item => !fromSet.has(genKey(item)));

    if (!isEmpty(diff)) {
      this.isEmpty = false;
    }

    return diff;
```
created_at: 2025-04-08T09:12:38Z
author_association: OWNER
diff_hunk: @@ -290,10 +290,24 @@ class JsonTransformer {
     fromMeta: XmlContent[],
     toMeta: XmlContent[]
   ): XmlContent[] {
-    const diff = differenceWith(toMeta, fromMeta, deepEqual)
+    // Build map of fromMeta elements by their stringified representation
+    const fromMap = new Map<string, XmlContent>()
+    for (const item of fromMeta) {
+      // Create a stable string key from the item
+      const key = JSON.stringify(item)
path: src/utils/metadataDiff.ts
user: login: matdodgson
body: Nice one! Using Set() is definitely cleaner.

Here are some basic benchmarks I did of 3 versions (my original commit, using Object.values() and JSON.stringify with Set()).

<img width="523" alt="sfdx git delta perf tests Screenshot 2025-04-10 162642" src="https://github.com/user-attachments/assets/51424316-eed0-4826-8b3d-f22e2981cbc0" />

Performance of Object.values().join() appears to be slightly faster on the objects I'm testing against (7 fields Workflow Alert).

Thanks for your point about which objects are being compared with each other. So only the same sub type within the same document are compared.

Regarding the point of objects within objects. Docs for WorkflowAlert: https://developer.salesforce.com/docs/atlas.en-us.api_meta.meta/api_meta/meta_workflow.htm#WorkflowAlert. Docs say it contains the recipients attribute which is an object. When I debug getPartialContentWithKey when genKey uses Object.values().join(), I get something like this:

```
'TestEmailAlert0-awesome0-false-[object Object]-awesome@awesome.com-OrgWideEmailAddress-None'
```

Any suggestions how to deal with [object Object]?
created_at: 2025-04-10T06:40:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -260,8 +261,10 @@ class JsonTransformer {
       return this.getPartialContentWithoutKey(fromMeta, toMeta)
     } else if (keyField === ARRAY_SPECIAL_KEY) {
       return this.getPartialContentForArray(fromMeta, toMeta)
+    } else if (keyField === OBJECT_SPECIAL_KEY) {
+      return this.getPartialContentForObject(fromMeta, toMeta)
path: src/utils/metadataDiff.ts
user: login: qlty-cloud-legacy[bot]
body: Avoid too many `return` statements within this function.
created_at: 2025-04-10T07:20:50Z
author_association: NONE
diff_hunk: @@ -260,8 +261,10 @@ class JsonTransformer {
       return this.getPartialContentWithoutKey(fromMeta, toMeta)
     } else if (keyField === ARRAY_SPECIAL_KEY) {
       return this.getPartialContentForArray(fromMeta, toMeta)
+    } else if (keyField === OBJECT_SPECIAL_KEY) {
+      return this.getPartialContentForObject(fromMeta, toMeta)
     } else {
-      return this.getPartialContentWithKey(fromMeta, toMeta)
+      return this.getPartialContentWithKey(fromMeta, toMeta, keyField)
path: src/utils/metadataDiff.ts
user: login: qlty-cloud-legacy[bot]
body: Avoid too many `return` statements within this function.
created_at: 2025-04-10T07:20:50Z
author_association: NONE
diff_hunk: @@ -290,10 +290,24 @@ class JsonTransformer {
     fromMeta: XmlContent[],
     toMeta: XmlContent[]
   ): XmlContent[] {
-    const diff = differenceWith(toMeta, fromMeta, deepEqual)
+    // Build map of fromMeta elements by their stringified representation
+    const fromMap = new Map<string, XmlContent>()
+    for (const item of fromMeta) {
+      // Create a stable string key from the item
+      const key = JSON.stringify(item)
path: src/utils/metadataDiff.ts
user: login: scolladon
body: Nice catch for the `WorkflowAlert`!
Thanks for the benchmark, very interesting

I look into this and I think we could use a [fast node hash lib](https://github.com/SkeLLLa/node-object-hash#readme), what do you think ?
It could replace JSON.stringify (which could be costly in CPU and memory) or any custom implementation we would have to maintain
created_at: 2025-04-10T07:37:55Z
author_association: OWNER
diff_hunk: @@ -286,14 +291,45 @@ class JsonTransformer {
     return []
   }
 
-  private getPartialContentWithKey(
+  private getPartialContentForObject(
     fromMeta: XmlContent[],
     toMeta: XmlContent[]
   ): XmlContent[] {
-    const diff = differenceWith(toMeta, fromMeta, deepEqual)
+    const genKey = (item: XmlContent[0]) => JSON.stringify(item)
+    // Build set of stringified fromMeta elements
+    const fromSet = new Set<string>(fromMeta.map(genKey))
+    // Filter toMeta to only include items not in fromSet
+    const diff = toMeta.filter(item => !fromSet.has(genKey(item)))
+
     if (!isEmpty(diff)) {
       this.isEmpty = false
     }
+
+    return diff
+  }
+
+  private getPartialContentWithKey(
+    fromMeta: XmlContent[],
+    toMeta: XmlContent[],
+    keyField: string
+  ): XmlContent[] {
+    const genKey = (item: XmlContent[0]) => item[keyField]
+    // Build map of keyField values to items
+    const fromMap = new Map(fromMeta.map(item => [genKey(item), item]))
+
+    // Filter toMeta to include items that:
+    // 1. Don't exist in fromMap (new items)
+    // 2. Exist but have changed (modified items, detected by !deepEqual)
+    const diff = toMeta.filter(item => {
+      const key = genKey(item)
+      const fromItem = fromMap.get(key)
+      return !fromItem || !deepEqual(item, fromItem)
+    })
+
+    if (!isEmpty(diff)) {
+      this.isEmpty = false
+    }
+
path: src/utils/metadataDiff.ts
user: login: scolladon
body: **nitpick**: 💅  I think both methods could be refactored

```suggestion
  private getPartialContentForObject(
    fromMeta: XmlContent[],
    toMeta: XmlContent[]
  ): XmlContent[] {
    const keySelector = (item: XmlContent[0]) => JSON.stringify(item) // We could use a fast hash
    return this.getPartialContentWithSelector(fromMeta, toMeta, keySelector)
  }

  private getPartialContentWithKey(
    fromMeta: XmlContent[],
    toMeta: XmlContent[],
    keyField: string
  ): XmlContent[] {
    const keySelector = (item: XmlContent[0]) => item[keyField]
    const fromMap = new Map(fromMeta.map(item => [keySelector(item), item]))
    return this.getPartialContentWithSelector(fromMeta, toMeta, keySelector)
  }

  private getPartialContentWithSelector(
    fromMeta: XmlContent[],
    toMeta: XmlContent[],
    keySelector: (item: XmlContent[0]) => string
  ): XmlContent[] {
    const fromSet = new Set<string>(fromMeta.map(keySelector))
    const diff = toMeta.filter(item => !fromSet.has(keySelector(item)))

    if (!isEmpty(diff)) {
      this.isEmpty = false
    }

    return diff
  }
```

Something like that (sorry, not tested locally)
created_at: 2025-04-10T07:45:15Z
author_association: OWNER
diff_hunk: @@ -286,14 +291,45 @@ class JsonTransformer {
     return []
   }
 
-  private getPartialContentWithKey(
+  private getPartialContentForObject(
     fromMeta: XmlContent[],
     toMeta: XmlContent[]
   ): XmlContent[] {
-    const diff = differenceWith(toMeta, fromMeta, deepEqual)
+    const genKey = (item: XmlContent[0]) => JSON.stringify(item)
+    // Build set of stringified fromMeta elements
+    const fromSet = new Set<string>(fromMeta.map(genKey))
+    // Filter toMeta to only include items not in fromSet
+    const diff = toMeta.filter(item => !fromSet.has(genKey(item)))
+
     if (!isEmpty(diff)) {
       this.isEmpty = false
     }
+
+    return diff
+  }
+
+  private getPartialContentWithKey(
+    fromMeta: XmlContent[],
+    toMeta: XmlContent[],
+    keyField: string
+  ): XmlContent[] {
+    const genKey = (item: XmlContent[0]) => item[keyField]
+    // Build map of keyField values to items
+    const fromMap = new Map(fromMeta.map(item => [genKey(item), item]))
+
+    // Filter toMeta to include items that:
+    // 1. Don't exist in fromMap (new items)
+    // 2. Exist but have changed (modified items, detected by !deepEqual)
+    const diff = toMeta.filter(item => {
+      const key = genKey(item)
+      const fromItem = fromMap.get(key)
+      return !fromItem || !deepEqual(item, fromItem)
+    })
+
+    if (!isEmpty(diff)) {
+      this.isEmpty = false
+    }
+
path: src/utils/metadataDiff.ts
user: login: matdodgson
body: I want to be clear about the requirements here - please let me know if I'm missing something. In getPartialContent we have:

1. no key field: compare the 2 arrays. Always return toMeta
2. ```<array>```: compare the 2 arrays. If the whole arrays are identical we return toMeta, otherwise empty array
3. ```<object>```: compare the 2 arrays. Where an object in toMeta is not in fromMeta we include that in the result. This is detecting new or modified objects
4. a defined key: compare the 2 arrays. Use the key from toMeta to lookup the item in fromMeta but also compare their contents. Where an item in toMeta is not in fromMeta we include that in the result

Some observations:
- Implementation aside, it looks to me like options 3 & 4 are the trying to achieve the same outcome
- I can't see a significant advantage having two versions to find/compare the objects (3 and 4). However a clear advantage to having one version is that there's half as much code
- Both Map and Set keys in V8 are hashes so the size of the key doesn't matter. JSON.stringify on each whole object works correctly and is performant
- Keep in mind that the performance results I posted are for comparing arrays of 5000 objects against each other. So for 2 arrays of 5000 objects with this PR we've gone from 5 seconds to 100 milliseconds.

created_at: 2025-04-11T05:15:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -290,10 +290,24 @@ class JsonTransformer {
     fromMeta: XmlContent[],
     toMeta: XmlContent[]
   ): XmlContent[] {
-    const diff = differenceWith(toMeta, fromMeta, deepEqual)
+    // Build map of fromMeta elements by their stringified representation
+    const fromMap = new Map<string, XmlContent>()
+    for (const item of fromMeta) {
+      // Create a stable string key from the item
+      const key = JSON.stringify(item)
path: src/utils/metadataDiff.ts
user: login: matdodgson
body: Regarding JSON.stringify I don't think it's costly CPU or memory wise. It's implemented in C++ in V8 (ie. it's fast) and the result goes into the Set as a key so it's those strings get cleaned up.

However fast node hash lib - which is a little slower than JSON.stringify - gives us something different which is that it sorts object keys where JSON.stringify does not. Is that a useful feature?

Here are the perf test results. Remember each of these results is a run comparing an array of 5000 objects against another of 5000 objects - which is quite a lot:

<img width="1064" alt="sgd cpu usage Screenshot 2025-04-11 165237" src="https://github.com/user-attachments/assets/b3d8ba80-4a42-4dae-9442-3ae0459a8099" />

created_at: 2025-04-11T05:46:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -290,10 +290,24 @@ class JsonTransformer {
     fromMeta: XmlContent[],
     toMeta: XmlContent[]
   ): XmlContent[] {
-    const diff = differenceWith(toMeta, fromMeta, deepEqual)
+    // Build map of fromMeta elements by their stringified representation
+    const fromMap = new Map<string, XmlContent>()
+    for (const item of fromMeta) {
+      // Create a stable string key from the item
+      const key = JSON.stringify(item)
path: src/utils/metadataDiff.ts
user: login: matdodgson
body: I added a memory test. Here are the results of JSON.stringify vs hasher.sort vs hasher.hash vs main branch. Heap usage wise it's JSON.stringify < hasher.sort < hasher.hash < main branch:

<img width="839" alt="sgd memory usage Screenshot 2025-04-11 165302" src="https://github.com/user-attachments/assets/d628d027-dc93-43ac-8e2f-759fb7be8c5f" />

created_at: 2025-04-11T06:38:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -290,10 +290,24 @@ class JsonTransformer {
     fromMeta: XmlContent[],
     toMeta: XmlContent[]
   ): XmlContent[] {
-    const diff = differenceWith(toMeta, fromMeta, deepEqual)
+    // Build map of fromMeta elements by their stringified representation
+    const fromMap = new Map<string, XmlContent>()
+    for (const item of fromMeta) {
+      // Create a stable string key from the item
+      const key = JSON.stringify(item)
path: src/utils/metadataDiff.ts
user: login: matdodgson
body: I run my memory test like this:

```bash
NODE_OPTIONS=--expose-gc npx jest __tests__/unit/lib/utils/metadataDiffLarge.test.ts
```
created_at: 2025-04-11T06:38:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -286,14 +291,45 @@ class JsonTransformer {
     return []
   }
 
-  private getPartialContentWithKey(
+  private getPartialContentForObject(
     fromMeta: XmlContent[],
     toMeta: XmlContent[]
   ): XmlContent[] {
-    const diff = differenceWith(toMeta, fromMeta, deepEqual)
+    const genKey = (item: XmlContent[0]) => JSON.stringify(item)
+    // Build set of stringified fromMeta elements
+    const fromSet = new Set<string>(fromMeta.map(genKey))
+    // Filter toMeta to only include items not in fromSet
+    const diff = toMeta.filter(item => !fromSet.has(genKey(item)))
+
     if (!isEmpty(diff)) {
       this.isEmpty = false
     }
+
+    return diff
+  }
+
+  private getPartialContentWithKey(
+    fromMeta: XmlContent[],
+    toMeta: XmlContent[],
+    keyField: string
+  ): XmlContent[] {
+    const genKey = (item: XmlContent[0]) => item[keyField]
+    // Build map of keyField values to items
+    const fromMap = new Map(fromMeta.map(item => [genKey(item), item]))
+
+    // Filter toMeta to include items that:
+    // 1. Don't exist in fromMap (new items)
+    // 2. Exist but have changed (modified items, detected by !deepEqual)
+    const diff = toMeta.filter(item => {
+      const key = genKey(item)
+      const fromItem = fromMap.get(key)
+      return !fromItem || !deepEqual(item, fromItem)
+    })
+
+    if (!isEmpty(diff)) {
+      this.isEmpty = false
+    }
+
path: src/utils/metadataDiff.ts
user: login: scolladon
body: You're right !
Sorry I went fast (too fast) on this one and the 3 and 4 are different.
In the 4, as we find same keys we need to check if they are different.
created_at: 2025-04-11T12:41:11Z
author_association: OWNER
diff_hunk: @@ -290,10 +290,24 @@ class JsonTransformer {
     fromMeta: XmlContent[],
     toMeta: XmlContent[]
   ): XmlContent[] {
-    const diff = differenceWith(toMeta, fromMeta, deepEqual)
+    // Build map of fromMeta elements by their stringified representation
+    const fromMap = new Map<string, XmlContent>()
+    for (const item of fromMeta) {
+      // Create a stable string key from the item
+      const key = JSON.stringify(item)
path: src/utils/metadataDiff.ts
user: login: scolladon
body: That's very clear, thank you

So, from what I understand, `JSON.stringify` is very fast, faster then a fast hash.
`JSON.stringify` is slightly slower than a custom "`join`" key generator, but it deals with attributs containing objects.
=> Go for JSON.stringify for the case 3 ("<object>")

Using the key to only compare  when it is in both version is faster than `JSON.stringify` (less comparison).
=> Question is : Does `JSON.stringify` comparison is faster than `deepEqual` ? (if yes then not only `prune` but also `compare` could benefit from this boost) ?
created_at: 2025-04-11T12:48:35Z
author_association: OWNER
diff_hunk: @@ -290,10 +290,24 @@ class JsonTransformer {
     fromMeta: XmlContent[],
     toMeta: XmlContent[]
   ): XmlContent[] {
-    const diff = differenceWith(toMeta, fromMeta, deepEqual)
+    // Build map of fromMeta elements by their stringified representation
+    const fromMap = new Map<string, XmlContent>()
+    for (const item of fromMeta) {
+      // Create a stable string key from the item
+      const key = JSON.stringify(item)
path: src/utils/metadataDiff.ts
user: login: scolladon
body: I have tested replacing `deepEqual` by `JSON.stringify` and `deepEqual` seems slightly faster and more reliable.
I have added few commits just to go in the direction that seems to be the best from my understanding of your explorations.
Let me know if it is ok from your point of view
created_at: 2025-04-11T15:21:32Z
author_association: OWNER
diff_hunk: @@ -290,10 +290,24 @@ class JsonTransformer {
     fromMeta: XmlContent[],
     toMeta: XmlContent[]
   ): XmlContent[] {
-    const diff = differenceWith(toMeta, fromMeta, deepEqual)
+    // Build map of fromMeta elements by their stringified representation
+    const fromMap = new Map<string, XmlContent>()
+    for (const item of fromMeta) {
+      // Create a stable string key from the item
+      const key = JSON.stringify(item)
path: src/utils/metadataDiff.ts
user: login: matdodgson
body: Nice one! The latest version is slightly faster than all other versions - let's go with it :+1: 
created_at: 2025-04-12T05:12:09Z
author_association: CONTRIBUTOR

PR comments:
user: login: codecov[bot]
created_at: 2025-04-08T07:12:37Z
body: ## [Codecov](https://app.codecov.io/gh/scolladon/sfdx-git-delta/pull/1047?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=S%C3%A9bastien+Colladon) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 100.00%. Comparing base [(`4c7e978`)](https://app.codecov.io/gh/scolladon/sfdx-git-delta/commit/4c7e978583b5b7ac50f5c210096c2a2812daf72c?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=S%C3%A9bastien+Colladon) to head [(`117711a`)](https://app.codecov.io/gh/scolladon/sfdx-git-delta/commit/117711a64a42b7e27758a475a6c6756fa6d8e27d?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=S%C3%A9bastien+Colladon).

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff            @@
##              main     #1047   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           45        45           
  Lines         1078      1092   +14     
  Branches       125       127    +2     
=========================================
+ Hits          1078      1092   +14     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/scolladon/sfdx-git-delta/pull/1047?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=S%C3%A9bastien+Colladon).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=S%C3%A9bastien+Colladon).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE
user: login: matdodgson
created_at: 2025-04-10T07:22:29Z
body: > That's a very good start !! Great job, thanks for the contribution
> 
> I think we could also work with the `getPartialContent` method by adding a 4th check
> 
> ```
> 	if (isUndefined(keyField)) {
>       return this.getPartialContentWithoutKey(fromMeta, toMeta)
>     } else if (keyField === ARRAY_SPECIAL_KEY) {
>       return this.getPartialContentForArray(fromMeta, toMeta)
>     } else if (keyField === OBJECT_SPECIAL_KEY) { // '<object>' => for layout assignment
>       return this.getPartialContentForObject(fromMeta, toMeta) // this should be the actual getPartialContentWithKey method
>     } else {
>       return this.getPartialContentWithKey(fromMeta, toMeta, keyField) //. this could be a new method using the keyField to compare object 
>     }
> ```
> ```

Nice one! I have updated the code to include this. Here's the benchmark (last column):

<img width="661" alt="sfdx git delta perf tests 2 Screenshot 2025-04-10 171838" src="https://github.com/user-attachments/assets/6924f0a3-1e05-4b8a-9511-555685a8c8a6" />

Latest version - where my test objects have a key field - looks to be slightly faster.
author_association: CONTRIBUTOR
user: login: qlty-cloud-legacy[bot]
created_at: 2025-04-12T08:11:37Z
body: Code Climate has analyzed commit 72b31661 and detected **2 issues** on this pull request.

Here's the issue category breakdown:

| Category | Count |
|:---:|:---:|
| Complexity | 2 |


View more on [Code Climate](https://codeclimate.com/github/scolladon/sfdx-git-delta/pull/1047).

author_association: NONE
user: login: github-actions[bot]
created_at: 2025-04-12T08:42:15Z
body: Shipped in [release `v6.6.0`]([v6.6.0](https://github.com/scolladon/sfdx-git-delta/releases/tag/v6.6.0)).
Version `v6.6.0` will be assigned to the `latest` npm channel soon
Install it using either `v6.6.0` or the `latest-rc` npm channel
```sh
$ sf plugins install sfdx-git-delta@latest-rc
# Or
$ sf plugins install sfdx-git-delta@v6.6.0
```
💡 Enjoying sfdx-git-delta?
Your contribution helps us provide fast support 🚀 and high quality features 🔥
Become a [sponsor](https://github.com/sponsors/scolladon) 💙
Happy incremental deployment!
author_association: NONE