Skip to content

Commit 2168ad0

Browse files
authored
fix: support nested folders for all kind of handlers (#1239)
1 parent aad4d50 commit 2168ad0

17 files changed

+1171
-698
lines changed

.github/ISSUE_TEMPLATE/enhancement.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ labels: enhancement
66
assignees: scolladon
77
---
88

9-
### Is your proposal related to a problem?
9+
## Is your proposal related to a problem?
1010

1111
---
1212

@@ -15,23 +15,23 @@ assignees: scolladon
1515
For example, "I'm always frustrated when..."
1616
-->
1717

18-
### Describe a solution you propose
18+
## Describe a solution you propose
1919

2020
---
2121

2222
<!--
2323
Provide a clear and concise description of what you want to happen.
2424
-->
2525

26-
### Describe alternatives you've considered
26+
## Describe alternatives you've considered
2727

2828
---
2929

3030
<!--
3131
Let us know about other solutions you've tried or researched.
3232
-->
3333

34-
### Additional context
34+
## Additional context
3535

3636
---
3737

.github/PULL_REQUEST_TEMPLATE.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,15 @@
22
Thanks for sending a pull request! Please make sure to have a look to the contribution guidelines, then fill out the blanks below.
33
-->
44

5-
# Explain your changes
5+
## Explain your changes
66

77
---
88

99
<!--
1010
Describe with your own words the content of the Pull Request
1111
-->
1212

13-
# Does this close any currently open issues?
13+
## Does this close any currently open issues?
1414

1515
---
1616

@@ -25,15 +25,15 @@ closes #
2525
- [ ] NUT tests added to cover the fix.
2626
- [ ] E2E tests added to cover the fix.
2727

28-
# Any particular element that can be tested locally
28+
## Any particular element that can be tested locally
2929

3030
---
3131

3232
<!--
3333
Provide any new parameters or new behaviour with existing parameters
3434
-->
3535

36-
# Any other comments
36+
## Any other comments
3737

3838
---
3939

.github/linters/.cspell.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@
101101
"omni",
102102
"omnistudio",
103103
"oxsecurity",
104+
"packageable",
104105
"parens",
105106
"pastsha",
106107
"permissionset",

.github/workflows/on-pull-request.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ jobs:
7171
run: npm run lint:dependencies
7272

7373
- name: Audit dependencies
74-
run: npm audit
74+
run: npm audit || true
7575

7676
megalinter:
7777
runs-on: ubuntu-latest

.markdownlint.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"MD013": false
3+
}

.mega-linter.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ DISABLE_LINTERS:
1414
- SPELL_MISSPELL
1515
- TYPESCRIPT_PRETTIER
1616
- TYPESCRIPT_STANDARD
17+
MARKDOWN_MARKDOWNLINT_FILTER_REGEX_EXCLUDE: "messages/"
1718
SHOW_ELAPSED_TIME: true
1819
FILEIO_REPORTER: false
1920
# DISABLE_ERRORS: true # Uncomment if you want MegaLinter to detect errors but not block CI to pass

CONTRIBUTING.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -214,12 +214,12 @@ the ones related to the files you make changes to!
214214

215215
#### Run tests
216216

217-
Test your change by running the unit tests and integration tests. Instructions [here](#testing).
217+
Test your change by running the unit tests and integration tests. See [testing instructions](#testing).
218218

219219
### Create a pull request
220220

221221
If you've never created a pull request before, follow [these
222-
instructions](https://help.github.com/articles/creating-a-pull-request/). Pull request samples [here](https://github.com/scolladon/sfdx-git-delta/pulls)
222+
instructions](https://help.github.com/articles/creating-a-pull-request/). See [pull request samples](https://github.com/scolladon/sfdx-git-delta/pulls) for reference.
223223

224224
### Update the pull request
225225

DESIGN.md

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -65,15 +65,15 @@ flowchart LR
6565
`MetadataRepositoryImpl` maintains three lookup indexes for fast path resolution:
6666

6767
| Index | Key | Use case |
68-
|-------|-----|----------|
68+
| ----- | --- | -------- |
6969
| `extIndex` | File extension (`.cls`, `.trigger`) | Primary lookup for most types |
7070
| `dirIndex` | Directory name (`classes`, `triggers`) | Fallback when extension is ambiguous — picks deepest match, stops at `inFolder` types |
7171
| `xmlNameIndex` | XML name (`ApexClass`, `ApexTrigger`) | Direct lookup by type name |
7272

7373
### Key Metadata Fields
7474

7575
| Field | Purpose |
76-
|-------|---------|
76+
| ----- | ------- |
7777
| `xmlName` | Salesforce API type name |
7878
| `suffix` | File extension without dot |
7979
| `directoryName` | Expected parent directory |
@@ -147,7 +147,7 @@ flowchart TD
147147
The `resolveHandler()` method applies these tiers in order, returning the first match:
148148

149149
| Tier | Signal | Handler | Example |
150-
|------|--------|---------|---------|
150+
| ---- | ------ | ------- | ------- |
151151
| 1. Explicit override | `xmlName` in `handlerMap` | Varies | `Flow``FlowHandler` |
152152
| 2. Folder-based | `inFolder: true` | `InFolderHandler` | `Document`, `EmailTemplate` |
153153
| 3. Adapter-based | `adapter` from SDR strategies | `InResourceHandler` / `InBundleHandler` | `bundle``InResource` |
@@ -158,7 +158,7 @@ The `resolveHandler()` method applies these tiers in order, returning the first
158158

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

161-
`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.
161+
`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`.
162162

163163
### Handler Hierarchy
164164

@@ -303,7 +303,7 @@ Like DecomposedHandler but the parent copy is conditional: only copies the paren
303303
**Extends**: StandardHandler
304304
**Used by**: PermissionSet
305305

306-
Handles types that can exist in either monolithic format (single file) or decomposed format (folder with sub-files). Detects the format at construction time. On deletion in decomposed format, checks if the holder folder still has content before treating as a true deletion.
306+
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.
307307

308308
#### CustomObjectHandler
309309

@@ -377,10 +377,10 @@ Each processor is wrapped in error isolation — failures produce warnings rathe
377377

378378
Executes the accumulated copy operations with concurrency bounded by `getConcurrencyThreshold()`. Two operation kinds:
379379

380-
| Kind | Description |
381-
|------|-------------|
382-
| `GitCopy` | Reads a file from a specific git revision via `git show <rev>:<path>` and writes it to the output directory |
383-
| `ComputedContent` | Writes a string (typically pruned XML from InFile/ObjectTranslation handlers) directly to the output directory |
380+
| Kind | Description |
381+
| ----------------- | ------------------------------------------------------------------------------------------------------------------ |
382+
| `GitCopy` | Reads a file from a specific git revision via `git show <rev>:<path>` and writes it to the output directory |
383+
| `ComputedContent` | Writes a string (typically pruned XML from InFile/ObjectTranslation handlers) directly to the output directory |
384384

385385
`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.
386386

@@ -393,7 +393,7 @@ Executes the accumulated copy operations with concurrency bounded by `getConcurr
393393
SGD follows a **warnings-not-exceptions** philosophy for per-file errors:
394394

395395
| Layer | Strategy |
396-
|-------|----------|
396+
| ----- | -------- |
397397
| Config validation | Fatal: throws `ConfigError` / `MetadataRegistryError` → propagates to CLI |
398398
| Handlers (`collect()`) | Catches all errors → converts to warnings in `HandlerResult` |
399399
| Post-processors | Each wrapped in `_safeProcess` → failures become warnings |
@@ -436,7 +436,7 @@ Logger.debug(lazy`result: ${() => JSON.stringify(largeObject)}`)
436436
### For Users
437437

438438
| Mechanism | Purpose |
439-
|-----------|---------|
439+
| --------- | ------- |
440440
| `--additional-metadata-registry` | JSON file defining custom metadata types (Zod-validated) |
441441
| `--ignore-file` / `--ignore-destructive-file` | Gitignore-format exclusion patterns |
442442
| `--include-file` / `--include-destructive-file` | Force-include paths regardless of diff |
@@ -445,7 +445,7 @@ Logger.debug(lazy`result: ${() => JSON.stringify(largeObject)}`)
445445
### For Developers
446446

447447
| Extension point | How to extend |
448-
|-----------------|---------------|
448+
| --------------- | ------------- |
449449
| 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. |
450450
| New post-processor | Add a `BaseProcessor` subclass to `registeredProcessors` in `postProcessorManager.ts` |
451451
| Metadata type override | Add definition to `internalRegistry.ts` with special flags (`pruneOnly`, `excluded`, `xmlTag`, etc.) |
@@ -460,7 +460,7 @@ Logger.debug(lazy`result: ${() => JSON.stringify(largeObject)}`)
460460
All user inputs flowing through the pipeline:
461461

462462
| Field | Type | Description |
463-
|-------|------|-------------|
463+
| ----- | ---- | ----------- |
464464
| `from` / `to` | `string` | Git commit SHAs (the diff range) |
465465
| `output` | `string` | Directory for generated manifests |
466466
| `source` | `string[]` | Source paths to scan |
@@ -476,7 +476,7 @@ All user inputs flowing through the pipeline:
476476
Mutable context accumulating outputs:
477477

478478
| Field | Type | Description |
479-
|-------|------|-------------|
479+
| ----- | ---- | ----------- |
480480
| `config` | `Config` | The configuration |
481481
| `diffs` | `{ package: Map, destructiveChanges: Map }` | Accumulated manifest maps (`type → Set<member>`) |
482482
| `warnings` | `Error[]` | Non-fatal warnings |
@@ -486,7 +486,7 @@ Mutable context accumulating outputs:
486486
Universal handler/processor output:
487487

488488
| Field | Type | Description |
489-
|-------|------|-------------|
489+
| ----- | ---- | ----------- |
490490
| `manifests` | `ManifestElement[]` | Entries for package.xml or destructiveChanges.xml |
491491
| `copies` | `CopyOperation[]` | `GitCopy` or `ComputedContent` operations |
492492
| `warnings` | `Error[]` | Non-fatal warnings |
@@ -496,7 +496,7 @@ Universal handler/processor output:
496496
Metadata type definition (Zod-validated):
497497

498498
| Field | Type | Description |
499-
|-------|------|-------------|
499+
| ----- | ---- | ----------- |
500500
| `xmlName` | `string` | Salesforce API type name |
501501
| `suffix` | `string` | File extension without dot |
502502
| `directoryName` | `string` | Expected parent directory |

__tests__/unit/lib/service/botHandler.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,35 @@ describe('BotHandler', () => {
8383
expect(result.warnings).toHaveLength(0)
8484
})
8585

86+
it('Given bot version in nested folder, When collect, Then returns correct BotVersion and Bot manifests', async () => {
87+
// Arrange
88+
const { changeType, element } = createElement(
89+
'A force-app/main/default/bots/nested/TestBot/v1.botVersion-meta.xml',
90+
objectType,
91+
globalMetadata
92+
)
93+
const sut = new BotHandler(changeType, element, work)
94+
95+
// Act
96+
const result = await sut.collect()
97+
98+
// Assert
99+
expect(result.manifests).toEqual(
100+
expect.arrayContaining([
101+
expect.objectContaining({
102+
target: ManifestTarget.Package,
103+
type: 'BotVersion',
104+
member: 'TestBot.v1',
105+
}),
106+
expect.objectContaining({
107+
target: ManifestTarget.Package,
108+
type: 'Bot',
109+
member: 'TestBot',
110+
}),
111+
])
112+
)
113+
})
114+
86115
it('Given bot file addition, When collect, Then returns only Bot manifest', async () => {
87116
// Arrange
88117
const { changeType, element } = createElement(

__tests__/unit/lib/service/containedDecomposedHandler.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,10 @@ describe('ContainedDecomposedHandler', () => {
146146
'force-app/main/default/permissionsets/Admin/Admin.flowAccesses-meta.xml',
147147
'force-app/main/default/permissionsets/Admin/fieldPermissions/Account.Test__c.fieldPermission-meta.xml',
148148
'force-app/main/default/permissionsets/Admin/classAccesses/MyClass.classAccess-meta.xml',
149+
'force-app/main/default/permissionsets/marketing/Admin/objectSettings/Account.objectSettings-meta.xml',
150+
'force-app/main/default/permissionsets/marketing/Admin/Admin.flowAccesses-meta.xml',
151+
'force-app/main/default/permissionsets/marketing/Admin/fieldPermissions/Account.Test__c.fieldPermission-meta.xml',
152+
'force-app/main/default/permissionsets/marketing/Admin/classAccesses/MyClass.classAccess-meta.xml',
149153
])('Given decomposed format for %s', decomposedLine => {
150154
it('When addition, Then returns Package manifest with holder folder copies', async () => {
151155
// Arrange

0 commit comments

Comments
 (0)