From fcd6a4304dbcb1a74fe1c981472e93430e3c896d Mon Sep 17 00:00:00 2001 From: Clifford Tawiah Date: Fri, 11 Sep 2026 19:04:12 -0400 Subject: [PATCH] feat(sync): derive local workspace identity --- internal/sync/source/resolver.go | 112 +++++++++++++++++++ internal/sync/source/resolver_test.go | 154 ++++++++++++++++++++++++++ 2 files changed, 266 insertions(+) create mode 100644 internal/sync/source/resolver.go create mode 100644 internal/sync/source/resolver_test.go diff --git a/internal/sync/source/resolver.go b/internal/sync/source/resolver.go new file mode 100644 index 00000000..27a0e078 --- /dev/null +++ b/internal/sync/source/resolver.go @@ -0,0 +1,112 @@ +package source + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + + "github.com/launchdarkly/ldcli/internal/config" + syncdomain "github.com/launchdarkly/ldcli/internal/sync" + "github.com/launchdarkly/ldcli/internal/sync/repository" +) + +type Workspace struct { + Root string + Source syncdomain.Source +} + +type Resolver struct { + configFile string + findGitSource func(string) (repository.GitRepository, bool, error) + ensureInstallationID func(string) (string, error) +} + +func NewResolver(configFile string) Resolver { + return Resolver{ + configFile: configFile, + findGitSource: repository.FindGitSource, + ensureInstallationID: config.EnsureInstallationID, + } +} + +func (resolver Resolver) Resolve(dir string) (Workspace, error) { + gitRepository, found, err := resolver.findGitSource(dir) + if err != nil { + return Workspace{}, err + } + if found { + root, err := canonicalPath(gitRepository.Root) + if err != nil { + return Workspace{}, err + } + + return Workspace{ + Root: root, + Source: gitRepository.Source, + }, nil + } + + root, err := localWorkspaceRoot(dir) + if err != nil { + return Workspace{}, err + } + + installationID, err := resolver.ensureInstallationID(resolver.configFile) + if err != nil { + return Workspace{}, err + } + + source, err := syncdomain.NewSource( + syncdomain.SourceTypeLocal, + localSourceIdentifier(installationID, root), + ) + if err != nil { + return Workspace{}, err + } + + return Workspace{Root: root, Source: source}, nil +} + +func localWorkspaceRoot(dir string) (string, error) { + root, err := canonicalPath(dir) + if err != nil { + return "", err + } + + for current := root; ; current = filepath.Dir(current) { + info, err := os.Stat(filepath.Join(current, syncdomain.RootDir)) + switch { + case err == nil && info.IsDir(): + return current, nil + case err != nil && !os.IsNotExist(err): + return "", fmt.Errorf("inspect workspace root: %w", err) + } + + parent := filepath.Dir(current) + if parent == current { + return root, nil + } + } +} + +func canonicalPath(path string) (string, error) { + absolute, err := filepath.Abs(path) + if err != nil { + return "", fmt.Errorf("resolve absolute workspace path: %w", err) + } + + resolved, err := filepath.EvalSymlinks(absolute) + if err != nil { + return "", fmt.Errorf("resolve workspace symlinks: %w", err) + } + + return filepath.Clean(resolved), nil +} + +func localSourceIdentifier(installationID, root string) string { + sum := sha256.Sum256([]byte(installationID + "\x00" + root)) + + return "sha256." + hex.EncodeToString(sum[:]) +} diff --git a/internal/sync/source/resolver_test.go b/internal/sync/source/resolver_test.go new file mode 100644 index 00000000..838ec046 --- /dev/null +++ b/internal/sync/source/resolver_test.go @@ -0,0 +1,154 @@ +package source + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" + "github.com/launchdarkly/ldcli/internal/sync/repository" +) + +func TestResolverUsesGitSourceWhenAvailable(t *testing.T) { + root := t.TempDir() + gitSource, err := syncdomain.NewSource( + syncdomain.SourceTypeGit, + "github.com/launchdarkly/ldcli", + ) + require.NoError(t, err) + + resolver := NewResolver(filepath.Join(t.TempDir(), "config.yml")) + resolver.findGitSource = func(string) (repository.GitRepository, bool, error) { + return repository.GitRepository{Root: root, Source: gitSource}, true, nil + } + resolver.ensureInstallationID = func(string) (string, error) { + t.Fatal("Git source must not create an installation ID") + + return "", nil + } + + workspace, err := resolver.Resolve(filepath.Join(root, "nested")) + + require.NoError(t, err) + assert.Equal(t, requireCanonicalPath(t, root), workspace.Root) + assert.Equal(t, gitSource, workspace.Source) +} + +func TestResolverUsesStableLocalSourceFromWorkspaceRoot(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(root, syncdomain.RootDir), 0o755)) + nested := filepath.Join(root, "services", "api") + require.NoError(t, os.MkdirAll(nested, 0o755)) + + resolver := localResolver("installation-id") + + first, err := resolver.Resolve(nested) + require.NoError(t, err) + second, err := resolver.Resolve(root) + require.NoError(t, err) + + expectedRoot := requireCanonicalPath(t, root) + assert.Equal(t, expectedRoot, first.Root) + assert.Equal(t, first, second) + assert.Equal(t, syncdomain.SourceTypeLocal, first.Source.Type()) + assert.Equal( + t, + localSourceIdentifier("installation-id", expectedRoot), + first.Source.Identifier(), + ) + assert.NotContains(t, first.Source.Identifier(), expectedRoot) + + _, err = os.Stat(filepath.Join(root, syncdomain.RootDir, "source.yaml")) + assert.ErrorIs(t, err, os.ErrNotExist) +} + +func TestResolverUsesCurrentDirectoryBeforeBootstrap(t *testing.T) { + root := t.TempDir() + resolver := localResolver("installation-id") + + workspace, err := resolver.Resolve(root) + + require.NoError(t, err) + expectedRoot := requireCanonicalPath(t, root) + assert.Equal(t, expectedRoot, workspace.Root) + assert.Equal( + t, + localSourceIdentifier("installation-id", expectedRoot), + workspace.Source.Identifier(), + ) +} + +func TestResolverCanonicalizesSymlinkedWorkspace(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(root, syncdomain.RootDir), 0o755)) + + link := filepath.Join(t.TempDir(), "workspace") + require.NoError(t, os.Symlink(root, link)) + + workspace, err := localResolver("installation-id").Resolve(link) + + require.NoError(t, err) + expectedRoot := requireCanonicalPath(t, root) + assert.Equal(t, expectedRoot, workspace.Root) + assert.Equal( + t, + localSourceIdentifier("installation-id", expectedRoot), + workspace.Source.Identifier(), + ) +} + +func TestResolverChangesLocalIdentityWhenWorkspaceMoves(t *testing.T) { + first, err := localResolver("installation-id").Resolve(t.TempDir()) + require.NoError(t, err) + second, err := localResolver("installation-id").Resolve(t.TempDir()) + require.NoError(t, err) + + assert.NotEqual(t, first.Source.Identifier(), second.Source.Identifier()) +} + +func TestResolverReturnsGitAndConfigErrors(t *testing.T) { + t.Run("invalid Git source", func(t *testing.T) { + resolver := localResolver("installation-id") + resolver.findGitSource = func(string) (repository.GitRepository, bool, error) { + return repository.GitRepository{}, false, errors.New("invalid origin") + } + + _, err := resolver.Resolve(t.TempDir()) + require.ErrorContains(t, err, "invalid origin") + }) + + t.Run("installation ID", func(t *testing.T) { + resolver := localResolver("installation-id") + resolver.ensureInstallationID = func(string) (string, error) { + return "", errors.New("config unavailable") + } + + _, err := resolver.Resolve(t.TempDir()) + require.ErrorContains(t, err, "config unavailable") + }) +} + +func localResolver(installationID string) Resolver { + resolver := NewResolver("config.yml") + resolver.findGitSource = func(string) (repository.GitRepository, bool, error) { + return repository.GitRepository{}, false, nil + } + resolver.ensureInstallationID = func(string) (string, error) { + return installationID, nil + } + + return resolver +} + +func requireCanonicalPath(t *testing.T, path string) string { + t.Helper() + + resolved, err := canonicalPath(path) + require.NoError(t, err) + + return resolved +}