Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/npm-service.yml
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ jobs:
message: |
Published under `${{ env.DEV_CHANNEL }}` npm channel.
```sh
$ sf plugins install sf-git-merge-driver@${{ env.DEV_CHANNEL }}
$ sf plugins install sfdx-git-delta@${{ env.DEV_CHANNEL }}
```
comment-tag: dev-publish
mode: recreate
Expand Down
52 changes: 42 additions & 10 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ flowchart LR
| `inFolder` | Whether the type uses folder-based organization |
| `content[]` | Sub-types sharing the same directory (Dashboard → DashboardFolder) |
| `xmlTag` + `key` | In-file diff semantics for sub-element types |
| `adapter` | SDR `strategies.adapter` value (e.g. `bundle`, `mixedContent`, `digitalExperience`) — drives dynamic handler resolution |
| `decomposition` | SDR `strategies.decomposition` value (e.g. `folderPerType`) — used for child type handler heuristics |
| `pruneOnly` | Type is only ever added to destructiveChanges, never package |
| `excluded` | Sub-element type is not independently packageable |

Expand Down Expand Up @@ -112,22 +114,50 @@ When `--ignore-whitespace` is enabled, `--word-diff-regex` and related flags are

**Entry**: `DiffLineInterpreter.process(lines)` (`src/service/diffLineInterpreter.ts`)

Each diff line is dispatched to a type-specific handler via an async queue capped at `getConcurrencyThreshold()`. The `TypeHandlerFactory` selects the handler class based on the metadata type's `xmlName`.
Each diff line is dispatched to a type-specific handler via an async queue capped at `getConcurrencyThreshold()`. The `TypeHandlerFactory` selects the handler class using a multi-tier resolution chain that combines explicit overrides with dynamic resolution from SDR registry attributes.

### Dispatch Flow

```mermaid
flowchart TD
Line["Diff line<br/>'A force-app/main/.../MyClass.cls'"] --> TF["TypeHandlerFactory"]
TF --> ME["MetadataBoundaryResolver<br/>creates MetadataElement"]
TF --> HM{"xmlName in<br/>handlerMap?"}
HM -->|Yes| SH["Specialized Handler"]
HM -->|No| DH["StandardHandler"]
SH --> C["handler.collect()"]
TF --> T1{"xmlName in<br/>handlerMap?"}
T1 -->|Yes| SH["Explicit Handler Override"]
T1 -->|No| T2{"inFolder?"}
T2 -->|Yes| IF["InFolderHandler"]
T2 -->|No| T3{"adapter in<br/>adapterHandlerMap?"}
T3 -->|Yes| AH["Adapter-Based Handler"]
T3 -->|No| T4{"has parentXmlName?"}
T4 -->|Yes| CH["Child Type Heuristics"]
T4 -->|No| T5{"parent of<br/>InFile children?"}
T5 -->|Yes| IFH["InFileHandler"]
T5 -->|No| DH["StandardHandler"]
CH --> C["handler.collect()"]
SH --> C
IF --> C
AH --> C
IFH --> C
DH --> C
C --> HR["HandlerResult<br/>{manifests, copies, warnings}"]
```

### Handler Resolution Tiers

The `resolveHandler()` method applies these tiers in order, returning the first match:

| Tier | Signal | Handler | Example |
|------|--------|---------|---------|
| 1. Explicit override | `xmlName` in `handlerMap` | Varies | `Flow` → `FlowHandler` |
| 2. Folder-based | `inFolder: true` | `InFolderHandler` | `Document`, `EmailTemplate` |
| 3. Adapter-based | `adapter` from SDR strategies | `InResourceHandler` / `InBundleHandler` | `bundle` → `InResource` |
| 4. Child heuristics | `xmlTag` + `key` + non-adapter parent | `DecomposedHandler` | `WorkflowAlert` |
| 4b. Child heuristics | no `xmlTag` + `folderPerType` parent | `CustomObjectChildHandler` | `ListView` |
| 5. InFile parent | has children with `xmlTag`+`key` | `InFileHandler` | `Workflow` |
| 6. Fallback | none of the above | `StandardHandler` | `ApexClass` |

This design means most new SDR metadata types are handled automatically without code changes. Only types requiring specialized behavior need explicit overrides in `handlerMap`.

`MetadataBoundaryResolver` creates a `MetadataElement` — a value object capturing the parsed identity of the diff line: base path, extension, parent folder, component name, and path segments after the type directory. It may scan the git tree to find the component root when the directory name isn't present in the path.

### Handler Hierarchy
Expand Down Expand Up @@ -201,7 +231,7 @@ Detects the format by file extension and routes accordingly.
#### InFolderHandler

**Extends**: StandardHandler
**Used by**: Document, EmailTemplate
**Used by**: Document, EmailTemplate (and any type with `inFolder: true` not explicitly overridden)

Handles metadata stored in named folders. When a file changes, the handler also copies the folder's `-meta.xml` descriptor and any companion files sharing the same base name (e.g. thumbnails). Element names use the `Folder/MemberName` format.

Expand Down Expand Up @@ -229,7 +259,7 @@ Extends shared folder behavior: changing any sub-file also forces inclusion of t
#### InResourceHandler

**Extends**: StandardHandler
**Used by**: ExperienceBundle, GenAiPlannerBundle, LightningTypeBundle, StaticResource, WaveTemplateBundle
**Used by**: ExperienceBundle, GenAiPlannerBundle, LightningTypeBundle, StaticResource, WaveTemplateBundle (and any type with `adapter: "bundle"` or `adapter: "mixedContent"` not explicitly overridden)

Handles bundle-like resources where changing any file within the bundle triggers the entire bundle to be redeployed. On deletion, checks if the bundle root still has content — if yes, treats as modification instead of deletion (the bundle still exists with remaining files).

Expand Down Expand Up @@ -257,7 +287,7 @@ Field translation files are not independently deployable. The handler produces a
#### DecomposedHandler

**Extends**: StandardHandler
**Used by**: SharingCriteriaRule, SharingGuestRule, SharingOwnerRule, Territory2, Territory2Rule, WorkflowAlert, WorkflowFieldUpdate, WorkflowFlowAction, WorkflowKnowledgePublish, WorkflowOutboundMessage, WorkflowRule, WorkflowSend, WorkflowTask
**Used by**: SharingCriteriaRule, SharingGuestRule, SharingOwnerRule, Territory2, Territory2Rule, WorkflowAlert, WorkflowFieldUpdate, WorkflowFlowAction, WorkflowKnowledgePublish, WorkflowOutboundMessage, WorkflowRule, WorkflowSend, WorkflowTask (and any child type with `xmlTag` + `key` whose parent has no adapter)

Handles metadata stored as individual files in sub-folders of a parent type. Element names are qualified as `ParentName.ChildName`. On addition, also copies the parent type's `-meta.xml`.

Expand Down Expand Up @@ -285,7 +315,7 @@ On addition, scans the object's `fields/` subfolder for Master Detail fields and
#### CustomObjectChildHandler

**Extends**: StandardHandler
**Used by**: BusinessProcess, CompactLayout, FieldSet, Index, ListView, RecordType, SharingReason, ValidationRule, WebLink
**Used by**: BusinessProcess, CompactLayout, FieldSet, Index, ListView, RecordType, SharingReason, ValidationRule, WebLink (and any child type without `xmlTag` whose parent has `decomposition: "folderPerType"`)

Handles child types living in CustomObject sub-folders. Element names are qualified as `ObjectName.ChildName`.

Expand Down Expand Up @@ -416,7 +446,7 @@ Logger.debug(lazy`result: ${() => JSON.stringify(largeObject)}`)

| Extension point | How to extend |
|-----------------|---------------|
| New metadata type handler | Add entry to `handlerMap` in `TypeHandlerFactory` mapping `xmlName → HandlerClass` |
| New metadata type handler | Most types are auto-resolved via SDR registry attributes (`adapter`, `decomposition`, `inFolder`, `xmlTag`+`key`). Only add an explicit entry to `handlerMap` in `TypeHandlerFactory` when a type needs behavior that differs from what SDR signals would select. |
| New post-processor | Add a `BaseProcessor` subclass to `registeredProcessors` in `postProcessorManager.ts` |
| Metadata type override | Add definition to `internalRegistry.ts` with special flags (`pruneOnly`, `excluded`, `xmlTag`, etc.) |
| Programmatic API | `import sgd from 'sfdx-git-delta'` — call `await sgd(config)` directly, receiving the `Work` object |
Expand Down Expand Up @@ -474,5 +504,7 @@ Metadata type definition (Zod-validated):
| `inFolder` | `boolean` | Folder-based organization |
| `content` | `Metadata[]` | Sub-types sharing the directory |
| `xmlTag` + `key` | `string` | In-file diff semantics |
| `adapter` | `string` | SDR `strategies.adapter` — drives handler auto-resolution |
| `decomposition` | `string` | SDR `strategies.decomposition` — child type heuristics |
| `pruneOnly` | `boolean` | Only in destructiveChanges |
| `excluded` | `boolean` | Not independently packageable |
52 changes: 52 additions & 0 deletions __tests__/unit/lib/metadata/sdrMetadataAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ describe('SDRMetadataAdapter', () => {
// Assert
const apexClass = metadata.find(m => m.xmlName === 'ApexClass')
expect(apexClass).toEqual({
adapter: 'default',
directoryName: 'classes',
inFolder: false,
metaFile: false,
Expand All @@ -77,6 +78,57 @@ describe('SDRMetadataAdapter', () => {
})
})

it('Given SDR type with strategies, When converting, Then stores adapter and decomposition', () => {
// Arrange
const mockRegistry: MockRegistry = {
types: {
customobject: {
id: 'customobject',
name: 'CustomObject',
directoryName: 'objects',
suffix: 'object',
strategies: {
adapter: 'decomposed',
decomposition: 'folderPerType',
transformer: 'decomposed',
},
},
},
}
const adapter = new SDRMetadataAdapter(mockRegistry as never)

// Act
const metadata = adapter.toInternalMetadata()

// Assert
const sut = metadata.find(m => m.xmlName === 'CustomObject')
expect(sut?.adapter).toBe('decomposed')
expect(sut?.decomposition).toBe('folderPerType')
})

it('Given SDR type without strategies, When converting, Then adapter and decomposition are absent', () => {
// Arrange
const mockRegistry: MockRegistry = {
types: {
flow: {
id: 'flow',
name: 'Flow',
directoryName: 'flows',
suffix: 'flow',
},
},
}
const adapter = new SDRMetadataAdapter(mockRegistry as never)

// Act
const metadata = adapter.toInternalMetadata()

// Assert
const sut = metadata.find(m => m.xmlName === 'Flow')
expect(sut?.adapter).toBeUndefined()
expect(sut?.decomposition).toBeUndefined()
})

it('Given SDR type with bundle adapter, When converting, Then metaFile is true', () => {
// Arrange
const mockRegistry: MockRegistry = {
Expand Down
66 changes: 66 additions & 0 deletions __tests__/unit/lib/service/typeHandlerFactory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import CustomField from '../../../../src/service/customFieldHandler'
import CustomObjectChildHandler from '../../../../src/service/customObjectChildHandler'
import Decomposed from '../../../../src/service/decomposedHandler'
import FlowHandler from '../../../../src/service/flowHandler'
import InBundleHandler from '../../../../src/service/inBundleHandler'
import InFileHandler from '../../../../src/service/inFileHandler'
import InFolder from '../../../../src/service/inFolderHandler'
import InResource from '../../../../src/service/inResourceHandler'
import ReportingFolderHandler from '../../../../src/service/reportingFolderHandler'
Expand Down Expand Up @@ -99,4 +101,68 @@ describe('the type handler factory', () => {
await typeHandlerFactory.getTypeHandler(`Z ${line}`)
).toBeInstanceOf(Standard)
})

it('Given deletion change type, When resolving handler, Then uses from revision', async () => {
const sut = await typeHandlerFactory.getTypeHandler(
`D force-app/main/default/classes/folder/file`
)
expect(sut).toBeInstanceOf(Standard)
})

describe('dynamic resolution', () => {
describe('adapter-based resolution', () => {
it('Given bundle adapter type, When resolving handler, Then returns InResource', async () => {
const sut = await typeHandlerFactory.getTypeHandler(
`Z force-app/main/default/aiAuthoringBundles/MyBundle/file.txt`
)
expect(sut).toBeInstanceOf(InResource)
})

it('Given mixedContent adapter type, When resolving handler, Then returns InResource', async () => {
const sut = await typeHandlerFactory.getTypeHandler(
`Z force-app/main/default/staticresources/MyResource/file.txt`
)
expect(sut).toBeInstanceOf(InResource)
})

it('Given digitalExperience adapter type, When resolving handler, Then returns InBundle', async () => {
const sut = await typeHandlerFactory.getTypeHandler(
`Z force-app/main/default/digitalExperiences/site/home/file.json`
)
expect(sut).toBeInstanceOf(InBundleHandler)
})
})

describe('child type resolution', () => {
it('Given child with xmlTag and key of non-decomposed parent, When resolving handler, Then returns Decomposed', async () => {
const sut = await typeHandlerFactory.getTypeHandler(
`Z force-app/main/default/workflows/Account/workflowAlerts/MyAlert.workflowAlert-meta.xml`
)
expect(sut).toBeInstanceOf(Decomposed)
})

it('Given child without xmlTag of folderPerType parent, When resolving handler, Then returns CustomObjectChildHandler', async () => {
const sut = await typeHandlerFactory.getTypeHandler(
`Z force-app/main/default/objects/Account/listViews/MyView.listView-meta.xml`
)
expect(sut).toBeInstanceOf(CustomObjectChildHandler)
})

it('Given child with parentXmlName not matching any child heuristic, When resolving handler, Then returns Standard', async () => {
const sut = await typeHandlerFactory.getTypeHandler(
`Z force-app/main/default/workSkillRoutingAttributes/MyRouting/MyAttribute.workSkillRoutingAttribute-meta.xml`
)
expect(sut).toBeInstanceOf(Standard)
})
})

describe('InFile parent resolution', () => {
it('Given parent type with children having xmlTag, When resolving handler, Then returns InFile', async () => {
const sut = await typeHandlerFactory.getTypeHandler(
`Z force-app/main/default/workflows/Account.workflow-meta.xml`
)
expect(sut).toBeInstanceOf(InFileHandler)
})
})
})
})
Loading
Loading