Skip to content
Draft
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
61 changes: 47 additions & 14 deletions internal/sync/detach/detach.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand All @@ -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]
Expand Down Expand Up @@ -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)
}
}
Expand All @@ -352,40 +381,44 @@ 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)
}
if exists {
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
}
40 changes: 40 additions & 0 deletions internal/sync/detach/detach_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
38 changes: 38 additions & 0 deletions internal/sync/local/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"

syncdomain "github.com/launchdarkly/ldcli/internal/sync"
)
Expand Down Expand Up @@ -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.
Expand Down
14 changes: 14 additions & 0 deletions internal/sync/local/source_reference_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading