This document describes the internal architecture of SGD for contributors and maintainers. It follows the execution pipeline from CLI invocation to output generation.
SGD processes git diffs through a linear pipeline with six stages:
flowchart TD
CLI["CLI Command<br/>(delta.ts)"] --> V["1. Config Validation<br/>(ConfigValidator)"]
V --> M["2. Metadata Registry<br/>(MetadataRepository)"]
M --> G["3. Git Diff Collection<br/>(RepoGitDiff)"]
G --> I["4. Diff Interpretation<br/>(DiffLineInterpreter → Handlers)"]
I --> P["5. Post-Processing<br/>(PostProcessorManager)"]
P --> IO["6. I/O Execution<br/>(IOExecutor + PackageGenerator)"]
style CLI fill:#e1f5fe
style IO fill:#e8f5e9
The pipeline is orchestrated by src/main.ts, which receives a Config object and returns a Work result containing the accumulated manifests and warnings.
Key design principle: collection is separated from execution. Handlers produce HandlerResult objects (manifest entries + copy operations) that are aggregated first, then written to disk only at the end. This allows deduplication and conflict resolution before any I/O occurs.
Entry: ConfigValidator.validateConfig() (src/utils/configValidator.ts)
Validates and normalizes user inputs:
- Resolves symbolic git refs (
HEAD, branch names) to full SHA viagit rev-parse - Validates that
fromandtoSHAs exist in the repository - Defaults
apiVersionfromsfdx-project.jsonor the latest SDR version; caps at the max supported version - Sanitizes file paths (output dir, source dirs, ignore files)
Fatal errors (ConfigError) at this stage abort the pipeline entirely. This is one of only two places where exceptions propagate to the CLI layer.
Entry: getDefinition(config) (src/metadata/metadataManager.ts)
Builds a MetadataRepository — the central lookup table mapping file paths to Salesforce metadata type definitions.
flowchart LR
SDR["@salesforce/source-deploy-retrieve<br/>registry"] --> Merge["MetadataDefinitionMerger"]
IR["internalRegistry<br/>(SGD overrides)"] --> Merge
AR["additionalMetadataRegistry<br/>(user-provided)"] --> Merge
Merge --> Repo["MetadataRepositoryImpl"]
- SDR registry — standard Salesforce metadata types from
@salesforce/source-deploy-retrieve, adapted viaSdrMetadataAdapter - Internal registry (
src/metadata/internalRegistry.ts) — SGD-specific overrides and additions (highest priority, overrides SDR byxmlName) - Additional registry (user-provided via
--additional-metadata-registry) — custom types, lowest priority
MetadataRepositoryImpl maintains three lookup indexes for fast path resolution:
| Index | Key | Use case |
|---|---|---|
extIndex |
File extension (.cls, .trigger) |
Primary lookup for most types |
dirIndex |
Directory name (classes, triggers) |
Fallback when extension is ambiguous — picks deepest match, stops at inFolder types |
xmlNameIndex |
XML name (ApexClass, ApexTrigger) |
Direct lookup by type name |
| Field | Purpose |
|---|---|
xmlName |
Salesforce API type name |
suffix |
File extension without dot |
directoryName |
Expected parent directory |
metaFile |
Whether a companion -meta.xml file exists |
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 |
Entry: RepoGitDiff.getLines() (src/utils/repoGitDiff.ts)
Collects diff lines between the from and to commits:
- Runs
git diff --numstat --no-renamesfor additions/modifications, andgit diff --numstat --no-renames --diff-filter=Dfor deletions - Filters lines through the metadata registry — only paths that resolve to a known metadata type are kept
- Applies ignore patterns (
IgnoreHelper) — separate global and destructive-only ignore files - Detects renames: paths where the fully-qualified name (case-insensitive) appears in both the deletion and addition sets have their deletion suppressed — a rename manifests only as an addition
When --ignore-whitespace is enabled, --word-diff-regex and related flags are added to filter whitespace-only changes. The diff uses --numstat format so each line starts with a change type character (A, M, D).
IgnoreHelper wraps the ignore library (gitignore spec) with a dual-instance pattern:
- globalIgnore: applied to all diff lines
- destructiveIgnore: applied only to deletions; falls back to globalIgnore if
--ignore-destructive-fileis not provided; always hard-codesrecordTypes/(Salesforce API limitation)
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 using a multi-tier resolution chain that combines explicit overrides with dynamic resolution from SDR registry attributes.
flowchart TD
Line["Diff line<br/>'A force-app/main/.../MyClass.cls'"] --> TF["TypeHandlerFactory"]
TF --> ME["MetadataBoundaryResolver<br/>creates MetadataElement"]
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}"]
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. Resolution follows a tiered strategy to minimize git I/O: (1) flat paths (single segment after the type directory) use MetadataElement.fromPath() directly; (2) types with no suffix (LWC, Aura) use fromPath() since the scan cannot identify them; (3) depth-2 paths where the file contains the metadata suffix extract the component name directly from the file name without git I/O; (4) deeper paths delegate to scanAndCreateElement() which calls getFilesPath(typeDir) — a single recursive git ls-tree -r per type directory, cached hierarchically — then builds a Set of component names from meta files and matches path segments inner-to-outer. This handles intermediate folders between the type directory and the component (e.g., permissionsets/marketing/Admin/... where marketing is an organizational folder, not the component). For paths where the type directory is not in the path, a fallback walk-up strategy lists directory siblings via listDirAtRevision and matches against known metadata suffixes, with results cached per revision via dirCache.
classDiagram
class StandardHandler {
+collect() HandlerResult
+collectAddition() HandlerResult
+collectDeletion() HandlerResult
+collectModification() HandlerResult
#_isProcessable() bool
#_getElementName() string
#_delegateFileCopy() bool
}
StandardHandler <|-- InFileHandler
StandardHandler <|-- InFolderHandler
StandardHandler <|-- InResourceHandler
StandardHandler <|-- SharedFolderHandler
StandardHandler <|-- DecomposedHandler
StandardHandler <|-- ContainedDecomposedHandler
StandardHandler <|-- CustomObjectHandler
StandardHandler <|-- CustomObjectChildHandler
StandardHandler <|-- FlowHandler
InFileHandler <|-- CustomLabelHandler
InFolderHandler <|-- ReportingFolderHandler
InResourceHandler <|-- BundleHandler
InResourceHandler <|-- LwcHandler
InResourceHandler <|-- ObjectTranslationHandler
SharedFolderHandler <|-- BotHandler
DecomposedHandler <|-- CustomFieldHandler
StandardHandler defines the fixed algorithm skeleton:
_isProcessable()— gate: does this file match the expected suffix?- Switch on change type →
collectAddition()/collectDeletion()/collectModification() - Errors are caught and converted to warnings — a single broken file does not abort processing
Subclasses override specific hooks to customize behavior. Even thin subclasses that override a single method justify their existence because they are selected at runtime based on metadata type definitions.
Extends: StandardHandler Used by: AssignmentRules, AutoResponseRules, EscalationRules, GlobalValueSetTranslation, MarketingAppExtension, MatchingRules, Profile, SharingRules, StandardValueSetTranslation, Translations, Workflow
Handles metadata types where multiple deployable sub-elements are stored in a single XML file. Uses MetadataDiff to compare both revisions of the file and produce per-sub-element manifest entries. Copies a pruned XML (computed content) containing only changed sub-elements instead of the full file.
Key behavior:
- Additions/modifications: XML diff produces fine-grained manifest entries per changed sub-element
- Deletions: if
pruneOnlyis set, treats as standard deletion; otherwise re-diffs to extract sub-element removals - File copy: disabled for standard git-copy; uses computed content (pruned XML) instead
Extends: InFileHandler Used by: CustomLabel
Handles CustomLabel which can exist in two formats:
- Monolithic: single
CustomLabels.labels-meta.xml— uses InFile XML diff behavior - Decomposed: individual
.label-meta.xmlfiles — uses StandardHandler behavior directly
Detects the format by file extension and routes accordingly.
Extends: StandardHandler
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.
Extends: InFolderHandler Used by: Dashboard, Report
Handles reporting types in shared directories where the actual Salesforce type is determined by file extension. An unrecognized extension silently produces an empty result. The manifest type name comes from the resolved extension, not the directory.
Extends: StandardHandler Used by: VirtualDiscovery, VirtualModeration, VirtualWave
Handles metadata in a shared directory where the type is resolved per file extension. Similar to ReportingFolderHandler but without the folder-meta copy logic.
Extends: SharedFolderHandler Used by: VirtualBot
Extends shared folder behavior: changing any sub-file also forces inclusion of the parent Bot manifest entry and its .bot-meta.xml.
Extends: StandardHandler
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).
Extends: InResourceHandler Used by: DigitalExperienceBundle
Like InResourceHandler but element names use two path segments (bundleType/bundleName) instead of one.
Extends: InResourceHandler Used by: AuraDefinitionBundle, GenAiFunction, LightningComponentBundle
Like InResourceHandler but skips files directly in the type directory (e.g. top-level __tests__); only processes files inside a named component sub-folder.
Extends: InResourceHandler Used by: CustomFieldTranslation, CustomObjectTranslation
Field translation files are not independently deployable. The handler produces a pruned version of the parent objectTranslation file containing only changed field translations, emitted as computed content.
Extends: StandardHandler
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.
Extends: DecomposedHandler Used by: CustomField
Like DecomposedHandler but the parent copy is conditional: only copies the parent CustomObject when the field contains <type>MasterDetail</type>, because Master Detail fields require the parent object in the same deployment.
Extends: StandardHandler Used by: PermissionSet
Handles types that can exist in either monolithic format (single file) or decomposed format (folder with sub-files). Detects the format at construction time. Locates the PermissionSet directory using a fixed offset from the file path's end, supporting arbitrary nesting depth (e.g., permissionsets/marketing/Admin/fieldPermissions/...). On deletion in decomposed format, checks if the holder folder still has content — if yes, treats as modification (redeploy the PS); if no, treats as true deletion.
Extends: StandardHandler Used by: CustomObject, Territory2Model
On addition, scans the object's fields/ subfolder for Master Detail fields and includes them in the copy set — Master Detail fields cannot be deployed in a subsequent step.
Extends: StandardHandler
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.
Extends: StandardHandler Used by: Flow
Standard behavior plus a warning on deletion — deleting a Flow requires manual deactivation first (Salesforce API limitation).
Entry: PostProcessorManager (src/post-processor/postProcessorManager.ts)
After handlers produce their results, post-processors run in two phases:
flowchart TD
HR["Handler Results"] --> A["aggregateManifests()"]
A --> C["Collectors phase<br/>(transformAndCollect)"]
C --> M["Merge + re-aggregate"]
M --> IO["I/O Execution"]
IO --> P["Processors phase<br/>(process)"]
subgraph Collectors
FT["FlowTranslationProcessor"]
IP["IncludeProcessor"]
end
subgraph Processors
PG["PackageGenerator"]
end
C --> FT
C --> IP
P --> PG
Collectors (isCollector = true) run first via collectAll(). They produce additional HandlerResult data that gets merged into the main result:
- FlowTranslationProcessor: when Flows are being deployed, uses
git grepwith pathspec globs (<source>/*<extension><metaFileSuffix>) to find.translation-meta.xmlfiles containingflowDefinitionselements matching deployed flows. This avoids requiring the tree index. Produces pruned translation files as computed content. - IncludeProcessor: handles
--includeand--include-destructiveflags. Lists all files in source directories, filters through include patterns, then processes matching lines throughDiffLineInterpreteras synthetic additions/deletions.
Processors (isCollector = false) run last via executeRemaining():
- PackageGenerator: the final step. Deduplicates manifests (removes entries from
destructiveChangesthat also appear inpackage), then writespackage.xml,destructiveChanges.xml, and the required companion emptypackage.xmlfor destructive deployments.
Each processor is wrapped in error isolation — failures produce warnings rather than crashing the pipeline.
Entry: IOExecutor.execute(copies) (src/adapter/ioExecutor.ts)
Executes the accumulated copy operations with concurrency bounded by getConcurrencyThreshold(). Three operation kinds:
| Kind | Description |
|---|---|
GitCopy |
Reads a single file from a specific git revision via the batch git cat-file process and writes it to the output directory. Does not require the tree index. |
GitDirCopy |
Enumerates all files under a directory path via getFilesPath (requires tree index), then copies each file via git cat-file. Used by handlers that need to copy entire directories (e.g., ContainedDecomposedHandler for decomposed PermissionSet holder folders). Executed serially because the outer execute() already parallelizes across operations. |
ComputedContent |
Writes a string (typically pruned XML from InFile/ObjectTranslation handlers) directly to the output directory |
GitAdapter.getBufferContent() handles LFS detection: if the buffer starts with an LFS pointer signature, it reads the actual object from the local LFS cache instead.
SGD follows a warnings-not-exceptions philosophy for per-file errors:
| Layer | Strategy |
|---|---|
| Config validation | Fatal: throws ConfigError / MetadataRegistryError → propagates to CLI |
Handlers (collect()) |
Catches all errors → converts to warnings in HandlerResult |
| Post-processors | Each wrapped in _safeProcess → failures become warnings |
| Git operations | Debug-logged, return empty/false → silent degradation |
| XML parsing | Produces "MalformedXML" warning with file path and revision |
This ensures a single broken file never aborts processing of the entire diff.
Error types form a hierarchy:
SgdError(base) — wraps original error ascauseConfigError— invalid user configurationMetadataRegistryError— invalid additional metadata registry
Every parallel operation uses getConcurrencyThreshold() from src/utils/concurrencyUtils.ts, which returns min(availableParallelism(), 6). The cap at 6 is a deliberate CI/CD safety constraint — the plugin runs on small CI machines.
The async library's queue, eachLimit, mapLimit, and filterLimit are used throughout. Unbounded Promise.all is never used for file or git operations.
Two complementary mechanisms:
lazy template tag (src/utils/LoggingService.ts): defers string interpolation until the log level is active. Expressions are evaluated eagerly by JavaScript, so expensive computations must be wrapped as arrow functions:
Logger.debug(lazy`result: ${() => JSON.stringify(largeObject)}`)@log decorator (src/utils/LoggingDecorator.ts): emits Logger.trace entry/exit on decorated methods. Works for both sync and async functions. Applied pervasively across handlers, adapters, and processors.
GitAdapter (src/adapter/GitAdapter.ts) wraps simple-git with a singleton pattern keyed by Config identity. It minimizes git subprocess spawns via two strategies:
Tree index: The tree index is a Set<string> of file paths per revision, populated exclusively by preBuildTreeIndex(revision, scopePaths) in src/main.ts. It serves pathExists, getFilesPath, and listDirAtRevision lookups without additional subprocess calls — if no index was pre-built for a revision, these methods return empty results. The index is scoped to reduce heap pressure: preBuildTreeIndex runs git ls-tree --name-only -r <revision> -- <path1> <path2> ... to index only the metadata directories that handlers actually need. Both revisions (config.to and config.from) are indexed in parallel via Promise.all. Scope computation (computeTreeIndexScope in src/utils/treeIndexScope.ts) analyzes diff lines against the metadata registry to determine which type directories require tree-index lookups — only types using InResource, InFolder, ReportingFolder, InBundle, Lwc, CustomObject, ContainedDecomposed, or MetadataBoundaryResolver deep-path resolution need the index. When --include or --include-destructive is set, the scope defaults to config.source since the include processor may touch any type. When generateDelta is false, the tree index is never built.
Batch cat-file: GitBatchCatFile (src/adapter/gitBatchCatFile.ts) spawns a single long-lived git cat-file --batch child process per adapter instance. File content reads write <revision>:<path>\n to stdin and parse the binary response from stdout using a FIFO queue. This replaces individual git show subprocess spawns.
Lifecycle: GitAdapter.closeAll() terminates all batch processes and clears the singleton instances map. It is called in a finally block in src/main.ts to prevent orphaned child processes on both success and error paths. If the git cat-file process exits unexpectedly, a close event handler rejects all pending promises to prevent hangs.
Memory note: The batch cat-file process buffers each blob's content entirely in memory before resolving. There is no upper bound on individual blob size — repositories with very large binary blobs (multi-GB) could cause high memory consumption. This is acceptable because SGD operates on trusted repositories and the previous git show implementation had the same characteristic.
| Mechanism | Purpose |
|---|---|
--additional-metadata-registry |
JSON file defining custom metadata types (Zod-validated) |
--ignore-file / --ignore-destructive-file |
Gitignore-format exclusion patterns |
--include-file / --include-destructive-file |
Force-include paths regardless of diff |
--source-dir (multiple) |
Scope diff to specific directories |
| Extension point | How to extend |
|---|---|
| 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 |
All user inputs flowing through the pipeline:
| Field | Type | Description |
|---|---|---|
from / to |
string |
Git commit SHAs (the diff range) |
output |
string |
Directory for generated manifests |
source |
string[] |
Source paths to scan |
repo |
string |
Git repository root |
apiVersion |
number |
Salesforce API version |
generateDelta |
boolean |
Whether to copy files (not just manifests) |
ignore / ignoreDestructive |
string |
Gitignore-style filter file paths |
include / includeDestructive |
string |
Force-include file paths |
ignoreWhitespace |
boolean |
Skip whitespace-only changes |
Mutable context accumulating outputs:
| Field | Type | Description |
|---|---|---|
config |
Config |
The configuration |
diffs |
{ package: Map, destructiveChanges: Map } |
Accumulated manifest maps (type → Set<member>) |
warnings |
Error[] |
Non-fatal warnings |
Universal handler/processor output:
| Field | Type | Description |
|---|---|---|
manifests |
ManifestElement[] |
Entries for package.xml or destructiveChanges.xml |
copies |
CopyOperation[] |
GitCopy, GitDirCopy, or ComputedContent operations |
warnings |
Error[] |
Non-fatal warnings |
Metadata type definition (Zod-validated):
| Field | Type | Description |
|---|---|---|
xmlName |
string |
Salesforce API type name |
suffix |
string |
File extension without dot |
directoryName |
string |
Expected parent directory |
metaFile |
boolean |
Companion -meta.xml exists |
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 |