From be85441b809e7c7a7c6620eaf0bce60e2d606a3b Mon Sep 17 00:00:00 2001 From: Cliff Tawiah Date: Fri, 30 Oct 2026 14:29:28 -0400 Subject: [PATCH] feat(sync): detach descriptor-backed sources --- internal/sync/detach/detach.go | 61 +++++++++++++++----- internal/sync/detach/detach_test.go | 40 +++++++++++++ internal/sync/local/delete.go | 38 ++++++++++++ internal/sync/local/source_reference_test.go | 14 +++++ 4 files changed, 139 insertions(+), 14 deletions(-) diff --git a/internal/sync/detach/detach.go b/internal/sync/detach/detach.go index 8ee4aa2c6..625240c96 100644 --- a/internal/sync/detach/detach.go +++ b/internal/sync/detach/detach.go @@ -229,14 +229,36 @@ func localChanges( ) map[syncdomain.ResourceID]bool { local := make(map[syncdomain.ResourceID]string) for _, variation := range variations { - if fingerprint, err := syncdomain.FingerprintVariation(variation.ProjectKey, variation.LookupKey, variation.Variation); err == nil { + var fingerprint string + var err error + if variation.Source != nil { + var projection syncdomain.VariationProjection + projection, err = syncdomain.ProjectVariation(variation.Variation, *variation.Source) + if err == nil { + fingerprint, err = syncdomain.FingerprintProjection(variation.ID(), projection) + } + } else { + fingerprint, err = syncdomain.FingerprintVariation(variation.ProjectKey, variation.LookupKey, variation.Variation) + } + if err == nil { local[variation.ID()] = fingerprint } } isSelected := func(id syncdomain.ResourceID) bool { return slices.Contains(selected, id) } attachments, _ := synclocal.CompileAttachments(repositoryRoot, isSelected) for _, attachment := range attachments { - if fingerprint, err := syncdomain.FingerprintAttachment(attachment.ProjectKey, attachment.Attachment); err == nil { + var fingerprint string + var err error + if attachment.Source != nil { + var projection syncdomain.Projection + projection, err = syncdomain.ProjectAttachment(attachment.Attachment, *attachment.Source) + if err == nil { + fingerprint, err = syncdomain.FingerprintProjection(attachment.ID(), projection) + } + } else { + fingerprint, err = syncdomain.FingerprintAttachment(attachment.ProjectKey, attachment.Attachment) + } + if err == nil { local[attachment.ID()] = fingerprint } } @@ -249,6 +271,9 @@ func localChanges( changed := make(map[syncdomain.ResourceID]bool) for _, file := range files { id, ok := synclocal.ParseManagedPath(file) + if !ok { + id, ok = synclocal.ParseReferencePath(file) + } if ok && isSelected(id) { fingerprint, readable := local[id] changed[id] = !readable || fingerprint != baseline[id] @@ -325,7 +350,11 @@ func loadResources( synced = append(synced, resource.ID()) } for _, file := range files { - if id, ok := synclocal.ParseManagedPath(file); ok { + id, ok := synclocal.ParseManagedPath(file) + if !ok { + id, ok = synclocal.ParseReferencePath(file) + } + if ok { synced = append(synced, id) } } @@ -352,16 +381,15 @@ func detachResources(options Options, original syncmanifest.Baseline, selected [ return errors.Join(cause, restoreErr) } - deletions, err := localAttachments(options.RepositoryRoot, selected) + deletions, err := localDefinitions(options.RepositoryRoot, selected) if err != nil { return restoreBaseline(err) } for _, id := range selected { - configKey, variationKey, err := id.VariationKeys() - if err != nil { + if slices.Contains(deletions, id) { continue } - exists, err := options.Store.VariationExists(id.ProjectKey, configKey, variationKey) + exists, err := options.Store.ResourceExists(id) if err != nil { return restoreBaseline(err) } @@ -369,23 +397,28 @@ func detachResources(options Options, original syncmanifest.Baseline, selected [ deletions = append(deletions, id) } } - if _, err := options.Store.DeleteResources(deletions); err != nil { + if _, err := options.Store.DeleteDefinitions(deletions); err != nil { return restoreBaseline(err) } return nil } -// localAttachments returns each selected tool and skill that has a local file. -func localAttachments(repositoryRoot string, selected []syncdomain.ResourceID) ([]syncdomain.ResourceID, error) { +// localDefinitions returns each selected resource that has a managed file or descriptor. +func localDefinitions(repositoryRoot string, selected []syncdomain.ResourceID) ([]syncdomain.ResourceID, error) { files, err := synclocal.SourceFiles(repositoryRoot) if err != nil { return nil, err } - var attachments []syncdomain.ResourceID + var definitions []syncdomain.ResourceID for _, file := range files { - if id, ok := synclocal.ParseManagedPath(file); ok && id.Kind != syncdomain.KindVariation && slices.Contains(selected, id) { - attachments = append(attachments, id) + id, ok := synclocal.ParseManagedPath(file) + if !ok { + id, ok = synclocal.ParseReferencePath(file) + } + if ok && slices.Contains(selected, id) { + definitions = append(definitions, id) } } - return attachments, nil + slices.SortFunc(definitions, syncdomain.CompareResourceIDs) + return slices.Compact(definitions), nil } diff --git a/internal/sync/detach/detach_test.go b/internal/sync/detach/detach_test.go index 9c6aa7427..9671d8742 100644 --- a/internal/sync/detach/detach_test.go +++ b/internal/sync/detach/detach_test.go @@ -96,6 +96,46 @@ func TestDetachResourcesKeepsAttachmentManifestEntries(t *testing.T) { require.NoError(t, err, "the tool file stays") } +func TestDetachResourcesRemovesDescriptorAndManifestButKeepsSource(t *testing.T) { + root := t.TempDir() + referencePath := filepath.Join(root, "prompts", "prompt.md") + require.NoError(t, os.MkdirAll(filepath.Dir(referencePath), 0o755)) + require.NoError(t, os.WriteFile(referencePath, []byte("Keep me.\n"), 0o644)) + + store := synclocal.NewStore(root) + resource := syncdomain.VariationID("project", "config", "prompt") + _, err := store.WriteDescriptor(resource, synclocal.Reference{ + File: "prompts/prompt.md", Format: "markdown", + }) + require.NoError(t, err) + + manifestStore := newMemoryManifestStore() + original := syncmanifest.Manifest{ + Resources: []syncmanifest.Resource{{ + ResourceKind: syncdomain.KindVariation, ProjectKey: "project", LookupKey: "config/prompt", + Fingerprint: testFingerprint(), + }}, + } + require.NoError(t, manifestStore.Write(original)) + + err = detachResources( + Options{RepositoryRoot: root, Store: store, Baselines: manifestStore}, + syncmanifest.Baseline{Lock: original}, + []syncdomain.ResourceID{resource}, + ) + + require.NoError(t, err) + exists, err := store.VariationExists("project", "config", "prompt") + require.NoError(t, err) + assert.False(t, exists) + _, err = os.Stat(referencePath) + require.NoError(t, err) + baseline, err := manifestStore.Load([]string{"project"}) + manifest := baseline.Lock + require.NoError(t, err) + assert.Empty(t, manifest.Resources) +} + func TestDetachResourcesRemovesManifestEntryWhenWrapperWasAlreadyDeleted(t *testing.T) { root := t.TempDir() store := synclocal.NewStore(root) diff --git a/internal/sync/local/delete.go b/internal/sync/local/delete.go index bc6985153..1f4d3cc3b 100644 --- a/internal/sync/local/delete.go +++ b/internal/sync/local/delete.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "strings" syncdomain "github.com/launchdarkly/ldcli/internal/sync" ) @@ -44,6 +45,43 @@ func (store Store) DeleteResources(ids []syncdomain.ResourceID) ([]string, error return store.deleteFiles(paths) } +// DeleteDefinitions deletes each descriptor when present, or its managed file. +// It never deletes the external file that a descriptor references. +func (store Store) DeleteDefinitions(ids []syncdomain.ResourceID) ([]string, error) { + paths := make([]string, 0, len(ids)) + for _, id := range ids { + reference, err := ReferencePath(id) + if err != nil { + return nil, err + } + reference = strings.TrimPrefix(reference, syncdomain.RootDir+"/") + _, referenceErr := os.Stat(store.absolute(reference)) + if referenceErr != nil && !errors.Is(referenceErr, os.ErrNotExist) { + return nil, fmt.Errorf("inspect descriptor %s: %w", id.Label(), referenceErr) + } + managed, err := resourcePath(id) + if err != nil { + return nil, err + } + _, managedErr := os.Stat(store.absolute(managed)) + if managedErr != nil && !errors.Is(managedErr, os.ErrNotExist) { + return nil, fmt.Errorf("inspect managed file %s: %w", id.Label(), managedErr) + } + if referenceErr == nil && managedErr == nil { + return nil, definitionConflict(id, id.Label()+" has a managed file and a descriptor") + } + if referenceErr == nil { + paths = append(paths, reference) + continue + } + if managedErr != nil { + return nil, fmt.Errorf("inspect definition %s: %w", id.Label(), managedErr) + } + paths = append(paths, managed) + } + return store.deleteFiles(paths) +} + // deleteFiles deletes a batch of managed files in one transaction. It first // moves every file to a backup beside it. If a move fails, it moves the // files back. When every file has moved, it removes the backups. diff --git a/internal/sync/local/source_reference_test.go b/internal/sync/local/source_reference_test.go index 3eb24bb13..4d14ccd99 100644 --- a/internal/sync/local/source_reference_test.go +++ b/internal/sync/local/source_reference_test.go @@ -445,6 +445,20 @@ func TestCommitFilesRollsBackEarlierReplacement(t *testing.T) { assert.Equal(t, "second original", string(second)) } +func TestDeleteDefinitionsKeepsExternalSource(t *testing.T) { + root := t.TempDir() + writeTestFile(t, root, "prompt.md", "Help.\n") + id := syncdomain.VariationID("project", "support", "default") + writeDescriptorForTest(t, root, id, Reference{File: "prompt.md", Format: "markdown"}) + + paths, err := NewStore(root).DeleteDefinitions([]syncdomain.ResourceID{id}) + + require.NoError(t, err) + assert.Equal(t, []string{"project/configs/support/default.ref.yaml"}, paths) + require.FileExists(t, filepath.Join(root, "prompt.md")) + require.NoFileExists(t, filepath.Join(root, ".launchdarkly", "project", "configs", "support", "default.ref.yaml")) +} + func writeDescriptorForTest(t *testing.T, root string, id syncdomain.ResourceID, reference Reference) { t.Helper() path, err := ReferencePath(id)