From 4cf3eb510ef2cf762550f32f8453fecc1c8f6710 Mon Sep 17 00:00:00 2001 From: Alex Warshavsky Date: Fri, 17 Jul 2026 16:35:42 -0700 Subject: [PATCH] feat: support sf/sfdx retrieve and deploy of recursively-foldered TagSet Add TagSet registry entry (inFolder for unbounded recursive foldering) and translate the on-disk slash-separated fullName to the org's dot-separated TagSet contract in constructFullName. Verified by a ComponentSet.getObject test asserting A/B/C to A.B.C in the package manifest. --- src/collections/componentSet.ts | 4 ++++ src/registry/metadataRegistry.json | 12 +++++++++++- test/collections/componentSet.test.ts | 14 ++++++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/collections/componentSet.ts b/src/collections/componentSet.ts index daa73dcf67..82f44bce33 100644 --- a/src/collections/componentSet.ts +++ b/src/collections/componentSet.ts @@ -816,6 +816,10 @@ const constructFullName = (registry: RegistryAccess, type: MetadataType, fullNam // a "/" so the metadata API can identify it as a folder. ['DashboardFolder', 'ReportFolder', 'EmailTemplateFolder'].includes(type.name) && !fullName.endsWith('/') ? `${fullName}/` + : // TagSet nests recursively by parent-set identity. inFolder stores it on disk as A/B/C, but the + // metadata API contract for TagSet fullNames is dot-separated (A.B.C), so translate on the way out. + type.name === 'TagSet' + ? fullName.replace(/\//g, '.') : registry.getParentType(type.name)?.strategies?.recomposition === 'startEmpty' && fullName.includes('.') ? // they're reassembled like CustomLabels.MyLabel fullName.split('.')[1] diff --git a/src/registry/metadataRegistry.json b/src/registry/metadataRegistry.json index a86a1dcbfd..79836e2073 100644 --- a/src/registry/metadataRegistry.json +++ b/src/registry/metadataRegistry.json @@ -604,7 +604,8 @@ "cnfgItemTypeRelationDef": "cnfgitemtyperelationdef", "cnfgMgmtRelationTypeDef": "cnfgmgmtrelationtypedef", "meetingPlaybookDefinition": "meetingplaybookdefinition", - "idpConfiguration": "idpconfiguration" + "idpConfiguration": "idpconfiguration", + "tagSet": "tagset" }, "types": { "accesscontrolpolicy": { @@ -5394,6 +5395,15 @@ "adapter": "bundle" }, "supportsPartialDelete": true + }, + "tagset": { + "id": "tagset", + "name": "TagSet", + "suffix": "tagSet", + "directoryName": "tagSet", + "inFolder": true, + "strictDirectoryName": false, + "supportsWildcardAndName": true } } } diff --git a/test/collections/componentSet.test.ts b/test/collections/componentSet.test.ts index 0a33929fc5..89957f250e 100644 --- a/test/collections/componentSet.test.ts +++ b/test/collections/componentSet.test.ts @@ -944,6 +944,20 @@ describe('ComponentSet', () => { ]); }); + it('should translate a recursively-foldered TagSet path to a dot-separated member', async () => { + // On disk, nested tag sets are stored as folders (tagSet/A/B/C.tagSet), so the resolved + // fullName is slash-separated. The TagSet metadata API contract is dot-separated. + const member = { fullName: 'DataGovernanceTags/CustomTags__as/CustomTagsChild1__as', type: 'TagSet' }; + const set = new ComponentSet([member], registryAccess); + + expect((await set.getObject()).Package.types).to.deep.equal([ + { + name: 'TagSet', + members: ['DataGovernanceTags.CustomTags__as.CustomTagsChild1__as'], + }, + ]); + }); + it('should include required child types as defined in the registry', async () => { const set = new ComponentSet([MATCHING_RULES_COMPONENT]); expect((await set.getObject()).Package.types).to.deep.equal([