From a31f5635cd3e8a0e338a1e36d1a1f3e4a3e98991 Mon Sep 17 00:00:00 2001 From: Clifford Tawiah Date: Wed, 9 Sep 2026 18:23:01 -0400 Subject: [PATCH 1/5] feat(sync): identify git repos and require origin for sync ldcli sync must run in an initialized git repository. Derive a stable repoIdentifier from origin so LaunchDarkly can track the same repo across branches. --- internal/sync/git.go | 133 ++++++++++++++++++++++ internal/sync/git_test.go | 224 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 357 insertions(+) create mode 100644 internal/sync/git.go create mode 100644 internal/sync/git_test.go diff --git a/internal/sync/git.go b/internal/sync/git.go new file mode 100644 index 00000000..f5e1d45c --- /dev/null +++ b/internal/sync/git.go @@ -0,0 +1,133 @@ +package sync + +import ( + "errors" + "fmt" + "net" + "net/url" + "os/exec" + "strings" +) + +var ( + ErrGitNotInstalled = errors.New("git is not installed; install git and initialize a repository before continuing") + ErrNotGitRepo = errors.New("not a git repository; run git init before continuing") + ErrNoOrigin = errors.New("repository has no origin remote; add a remote named origin before continuing") +) + +type Repo struct { + Root string + Identifier string +} + +type gitRunner interface { + lookPath(name string) (string, error) + output(dir string, args ...string) (string, error) +} + +type execGit struct{} + +func (execGit) lookPath(name string) (string, error) { + return exec.LookPath(name) +} + +func (execGit) output(dir string, args ...string) (string, error) { + cmd := exec.Command("git", args...) + cmd.Dir = dir + out, err := cmd.Output() + if err != nil { + return "", err + } + + return strings.TrimSpace(string(out)), nil +} + +func IdentifyRepo(dir string) (Repo, error) { + return identifyRepo(execGit{}, dir) +} + +func identifyRepo(git gitRunner, dir string) (Repo, error) { + if _, err := git.lookPath("git"); err != nil { + return Repo{}, ErrGitNotInstalled + } + + root, err := git.output(dir, "rev-parse", "--show-toplevel") + if err != nil { + return Repo{}, ErrNotGitRepo + } + + origin, err := git.output(root, "remote", "get-url", "origin") + if err != nil || origin == "" { + return Repo{}, ErrNoOrigin + } + + id, err := normalizeRemoteURL(origin) + if err != nil { + return Repo{}, err + } + + return Repo{Root: root, Identifier: id}, nil +} + +func normalizeRemoteURL(raw string) (string, error) { + s := strings.TrimSpace(raw) + if s == "" { + return "", errors.New("origin remote URL is empty") + } + + if !strings.Contains(s, "://") { + host, path, ok := scpRemote(s) + if !ok { + return "", fmt.Errorf("invalid origin remote %q", raw) + } + + return identifierFromHostPath(host, path) + } + + u, err := url.Parse(s) + if err != nil { + return "", fmt.Errorf("invalid origin remote %q", raw) + } + + return identifierFromHostPath(u.Hostname()+portSuffix(u.Port()), u.Path) +} + +func portSuffix(port string) string { + switch port { + case "", "22", "443": + return "" + default: + return ":" + port + } +} + +func scpRemote(s string) (host, path string, ok bool) { + userHost, path, found := strings.Cut(s, ":") + if !found || path == "" || strings.Contains(userHost, "/") { + return "", "", false + } + + _, host, found = strings.Cut(userHost, "@") + if !found { + host = userHost + } + + return host, path, host != "" +} + +func identifierFromHostPath(host, repoPath string) (string, error) { + host = strings.ToLower(strings.TrimSpace(host)) + if h, p, err := net.SplitHostPort(host); err == nil { + host = h + portSuffix(p) + } + + repoPath = strings.Trim(strings.ToLower(repoPath), "/") + repoPath = strings.TrimSuffix(repoPath, ".git") + repoPath = strings.Trim(repoPath, "/") + + if host == "" || repoPath == "" { + return "", fmt.Errorf("invalid origin remote %q", host+"/"+repoPath) + } + + return host + "/" + repoPath, nil +} diff --git a/internal/sync/git_test.go b/internal/sync/git_test.go new file mode 100644 index 00000000..7fb6399b --- /dev/null +++ b/internal/sync/git_test.go @@ -0,0 +1,224 @@ +package sync + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNormalizeRemoteURL(t *testing.T) { + tests := []struct { + in string + want string + }{ + {in: "git@github.com:Acme/Widgets.git", want: "github.com/acme/widgets"}, + {in: "https://github.com/Acme/Widgets.git", want: "github.com/acme/widgets"}, + {in: "https://github.com/Acme/Widgets", want: "github.com/acme/widgets"}, + {in: "https://github.com/Acme/Widgets/", want: "github.com/acme/widgets"}, + {in: "ssh://git@github.com/Acme/Widgets.git", want: "github.com/acme/widgets"}, + {in: "https://user:token@github.com/Acme/Widgets.git", want: "github.com/acme/widgets"}, + {in: "https://github.com:443/Acme/Widgets.git", want: "github.com/acme/widgets"}, + {in: "ssh://git@github.com:22/Acme/Widgets.git", want: "github.com/acme/widgets"}, + {in: "https://ghe.example.com:8443/Acme/Widgets.git", want: "ghe.example.com:8443/acme/widgets"}, + {in: "https://gitlab.com/group/sub/repo.git", want: "gitlab.com/group/sub/repo"}, + {in: "org-123@github.com:Acme/Widgets.git", want: "github.com/acme/widgets"}, + } + + for _, tt := range tests { + t.Run(tt.in, func(t *testing.T) { + got, err := normalizeRemoteURL(tt.in) + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestNormalizeRemoteURL_RejectsInvalid(t *testing.T) { + for _, in := range []string{"", " ", "not a remote", "://github.com/acme/widgets"} { + _, err := normalizeRemoteURL(in) + require.Error(t, err, in) + } +} + +func TestIdentifyRepo(t *testing.T) { + requireGit(t) + + dir := initGitRepo(t) + runGit(t, dir, "remote", "add", "origin", "git@github.com:Acme/Widgets.git") + + repo, err := IdentifyRepo(dir) + require.NoError(t, err) + assert.Equal(t, "github.com/acme/widgets", repo.Identifier) + assert.Equal(t, absPath(t, dir), absPath(t, repo.Root)) +} + +func TestIdentifyRepo_HTTPSMatchesSSH(t *testing.T) { + requireGit(t) + + sshDir := initGitRepo(t) + runGit(t, sshDir, "remote", "add", "origin", "git@github.com:Acme/Widgets.git") + + httpsDir := initGitRepo(t) + runGit(t, httpsDir, "remote", "add", "origin", "https://github.com/Acme/Widgets.git") + + sshRepo, err := IdentifyRepo(sshDir) + require.NoError(t, err) + httpsRepo, err := IdentifyRepo(httpsDir) + require.NoError(t, err) + + assert.Equal(t, sshRepo.Identifier, httpsRepo.Identifier) +} + +func TestIdentifyRepo_FromSubdirectory(t *testing.T) { + requireGit(t) + + dir := initGitRepo(t) + runGit(t, dir, "remote", "add", "origin", "https://github.com/Acme/Widgets.git") + + nested := filepath.Join(dir, "apps", "api") + require.NoError(t, os.MkdirAll(nested, 0o755)) + + repo, err := IdentifyRepo(nested) + require.NoError(t, err) + assert.Equal(t, "github.com/acme/widgets", repo.Identifier) + assert.Equal(t, absPath(t, dir), absPath(t, repo.Root)) +} + +func TestIdentifyRepo_StableAcrossBranches(t *testing.T) { + requireGit(t) + + dir := initGitRepo(t) + runGit(t, dir, "remote", "add", "origin", "https://github.com/Acme/Widgets.git") + require.NoError(t, os.WriteFile(filepath.Join(dir, "README"), []byte("x\n"), 0o644)) + runGit(t, dir, "add", "README") + runGit(t, dir, "commit", "--quiet", "-m", "init") + + main, err := IdentifyRepo(dir) + require.NoError(t, err) + + runGit(t, dir, "checkout", "-b", "feature") + + feature, err := IdentifyRepo(dir) + require.NoError(t, err) + assert.Equal(t, main.Identifier, feature.Identifier) +} + +func TestIdentifyRepo_NotARepo(t *testing.T) { + requireGit(t) + + _, err := IdentifyRepo(t.TempDir()) + require.ErrorIs(t, err, ErrNotGitRepo) +} + +func TestIdentifyRepo_NoOrigin(t *testing.T) { + requireGit(t) + + _, err := IdentifyRepo(initGitRepo(t)) + require.ErrorIs(t, err, ErrNoOrigin) +} + +func TestIdentifyRepo_GitNotInstalled(t *testing.T) { + _, err := identifyRepo(stubGit{pathErr: errors.New("not found")}, t.TempDir()) + require.ErrorIs(t, err, ErrGitNotInstalled) +} + +func TestIdentifyRepo_StubNotARepo(t *testing.T) { + _, err := identifyRepo(stubGit{ + errs: map[string]error{"rev-parse --show-toplevel": errors.New("fatal")}, + }, "/tmp/proj") + require.ErrorIs(t, err, ErrNotGitRepo) +} + +func TestIdentifyRepo_StubNoOrigin(t *testing.T) { + _, err := identifyRepo(stubGit{ + cmds: map[string]string{"rev-parse --show-toplevel": "/repo"}, + errs: map[string]error{"remote get-url origin": errors.New("no such remote")}, + }, "/repo") + require.ErrorIs(t, err, ErrNoOrigin) +} + +func TestIdentifyRepo_StubOrigin(t *testing.T) { + repo, err := identifyRepo(stubGit{ + cmds: map[string]string{ + "rev-parse --show-toplevel": "/repo", + "remote get-url origin": "https://github.com/Acme/Widgets.git", + }, + }, "/repo") + require.NoError(t, err) + assert.Equal(t, "/repo", repo.Root) + assert.Equal(t, "github.com/acme/widgets", repo.Identifier) +} + +type stubGit struct { + pathErr error + cmds map[string]string + errs map[string]error +} + +func (s stubGit) lookPath(string) (string, error) { + if s.pathErr != nil { + return "", s.pathErr + } + + return "/usr/bin/git", nil +} + +func (s stubGit) output(_ string, args ...string) (string, error) { + key := strings.Join(args, " ") + if s.errs != nil { + if err, ok := s.errs[key]; ok { + return "", err + } + } + if s.cmds != nil { + if out, ok := s.cmds[key]; ok { + return out, nil + } + } + + return "", errors.New("unexpected git " + key) +} + +func requireGit(t *testing.T) { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not installed") + } +} + +func initGitRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + runGit(t, dir, "init", "--quiet") + + return dir +} + +func runGit(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", append([]string{ + "-c", "user.name=ldcli", + "-c", "user.email=ldcli@example.com", + }, args...)...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "GIT_CONFIG_GLOBAL="+os.DevNull, + "GIT_CONFIG_NOSYSTEM=1", + ) + out, err := cmd.CombinedOutput() + require.NoError(t, err, string(out)) +} + +func absPath(t *testing.T, dir string) string { + t.Helper() + resolved, err := filepath.EvalSymlinks(dir) + require.NoError(t, err) + + return resolved +} From 88c4896175c1c39cd6d217e099883f0b5f158c6a Mon Sep 17 00:00:00 2001 From: Clifford Tawiah Date: Wed, 9 Sep 2026 18:39:43 -0400 Subject: [PATCH 2/5] refactor(sync): derive repo identifier from origin path Read the literal origin URL and reduce it to owner/repository. This keeps the identifier stable across branches and clone protocols without custom remote normalization. --- internal/sync/git.go | 74 +++++++-------------------------------- internal/sync/git_test.go | 66 +++++++++++++--------------------- 2 files changed, 37 insertions(+), 103 deletions(-) diff --git a/internal/sync/git.go b/internal/sync/git.go index f5e1d45c..250eb71a 100644 --- a/internal/sync/git.go +++ b/internal/sync/git.go @@ -3,7 +3,6 @@ package sync import ( "errors" "fmt" - "net" "net/url" "os/exec" "strings" @@ -56,78 +55,31 @@ func identifyRepo(git gitRunner, dir string) (Repo, error) { return Repo{}, ErrNotGitRepo } - origin, err := git.output(root, "remote", "get-url", "origin") + origin, err := git.output(root, "config", "--local", "--get", "remote.origin.url") if err != nil || origin == "" { return Repo{}, ErrNoOrigin } - id, err := normalizeRemoteURL(origin) + identifier, err := repoIdentifier(origin) if err != nil { return Repo{}, err } - return Repo{Root: root, Identifier: id}, nil + return Repo{Root: root, Identifier: identifier}, nil } -func normalizeRemoteURL(raw string) (string, error) { - s := strings.TrimSpace(raw) - if s == "" { - return "", errors.New("origin remote URL is empty") +func repoIdentifier(remote string) (string, error) { + repoPath := remote + if parsed, err := url.Parse(remote); err == nil && parsed.Host != "" { + repoPath = parsed.Path + } else if _, path, ok := strings.Cut(remote, ":"); ok { + repoPath = path } - if !strings.Contains(s, "://") { - host, path, ok := scpRemote(s) - if !ok { - return "", fmt.Errorf("invalid origin remote %q", raw) - } - - return identifierFromHostPath(host, path) - } - - u, err := url.Parse(s) - if err != nil { - return "", fmt.Errorf("invalid origin remote %q", raw) - } - - return identifierFromHostPath(u.Hostname()+portSuffix(u.Port()), u.Path) -} - -func portSuffix(port string) string { - switch port { - case "", "22", "443": - return "" - default: - return ":" + port - } -} - -func scpRemote(s string) (host, path string, ok bool) { - userHost, path, found := strings.Cut(s, ":") - if !found || path == "" || strings.Contains(userHost, "/") { - return "", "", false - } - - _, host, found = strings.Cut(userHost, "@") - if !found { - host = userHost - } - - return host, path, host != "" -} - -func identifierFromHostPath(host, repoPath string) (string, error) { - host = strings.ToLower(strings.TrimSpace(host)) - if h, p, err := net.SplitHostPort(host); err == nil { - host = h + portSuffix(p) - } - - repoPath = strings.Trim(strings.ToLower(repoPath), "/") - repoPath = strings.TrimSuffix(repoPath, ".git") - repoPath = strings.Trim(repoPath, "/") - - if host == "" || repoPath == "" { - return "", fmt.Errorf("invalid origin remote %q", host+"/"+repoPath) + parts := strings.Split(strings.TrimSuffix(strings.Trim(repoPath, "/"), ".git"), "/") + if len(parts) < 2 { + return "", fmt.Errorf("cannot derive repository identifier from origin %q", remote) } - return host + "/" + repoPath, nil + return strings.Join(parts[len(parts)-2:], "/"), nil } diff --git a/internal/sync/git_test.go b/internal/sync/git_test.go index 7fb6399b..70c061ee 100644 --- a/internal/sync/git_test.go +++ b/internal/sync/git_test.go @@ -12,38 +12,25 @@ import ( "github.com/stretchr/testify/require" ) -func TestNormalizeRemoteURL(t *testing.T) { - tests := []struct { - in string - want string - }{ - {in: "git@github.com:Acme/Widgets.git", want: "github.com/acme/widgets"}, - {in: "https://github.com/Acme/Widgets.git", want: "github.com/acme/widgets"}, - {in: "https://github.com/Acme/Widgets", want: "github.com/acme/widgets"}, - {in: "https://github.com/Acme/Widgets/", want: "github.com/acme/widgets"}, - {in: "ssh://git@github.com/Acme/Widgets.git", want: "github.com/acme/widgets"}, - {in: "https://user:token@github.com/Acme/Widgets.git", want: "github.com/acme/widgets"}, - {in: "https://github.com:443/Acme/Widgets.git", want: "github.com/acme/widgets"}, - {in: "ssh://git@github.com:22/Acme/Widgets.git", want: "github.com/acme/widgets"}, - {in: "https://ghe.example.com:8443/Acme/Widgets.git", want: "ghe.example.com:8443/acme/widgets"}, - {in: "https://gitlab.com/group/sub/repo.git", want: "gitlab.com/group/sub/repo"}, - {in: "org-123@github.com:Acme/Widgets.git", want: "github.com/acme/widgets"}, +func TestRepoIdentifier(t *testing.T) { + tests := map[string]string{ + "git@github.com:launchdarkly/ldcli.git": "launchdarkly/ldcli", + "https://github.com/launchdarkly/ldcli.git": "launchdarkly/ldcli", + "ssh://git@github.com/launchdarkly/ldcli.git": "launchdarkly/ldcli", } - for _, tt := range tests { - t.Run(tt.in, func(t *testing.T) { - got, err := normalizeRemoteURL(tt.in) + for remote, expected := range tests { + t.Run(remote, func(t *testing.T) { + actual, err := repoIdentifier(remote) require.NoError(t, err) - assert.Equal(t, tt.want, got) + assert.Equal(t, expected, actual) }) } } -func TestNormalizeRemoteURL_RejectsInvalid(t *testing.T) { - for _, in := range []string{"", " ", "not a remote", "://github.com/acme/widgets"} { - _, err := normalizeRemoteURL(in) - require.Error(t, err, in) - } +func TestRepoIdentifier_InvalidOrigin(t *testing.T) { + _, err := repoIdentifier("ldcli") + require.Error(t, err) } func TestIdentifyRepo(t *testing.T) { @@ -54,25 +41,20 @@ func TestIdentifyRepo(t *testing.T) { repo, err := IdentifyRepo(dir) require.NoError(t, err) - assert.Equal(t, "github.com/acme/widgets", repo.Identifier) + assert.Equal(t, "Acme/Widgets", repo.Identifier) assert.Equal(t, absPath(t, dir), absPath(t, repo.Root)) } -func TestIdentifyRepo_HTTPSMatchesSSH(t *testing.T) { +func TestIdentifyRepo_DoesNotExpandInsteadOf(t *testing.T) { requireGit(t) - sshDir := initGitRepo(t) - runGit(t, sshDir, "remote", "add", "origin", "git@github.com:Acme/Widgets.git") - - httpsDir := initGitRepo(t) - runGit(t, httpsDir, "remote", "add", "origin", "https://github.com/Acme/Widgets.git") + dir := initGitRepo(t) + runGit(t, dir, "remote", "add", "origin", "git@github.com:Acme/Widgets.git") + runGit(t, dir, "config", "--local", "url.ssh://git@github.com:443/.insteadOf", "git@github.com:") - sshRepo, err := IdentifyRepo(sshDir) - require.NoError(t, err) - httpsRepo, err := IdentifyRepo(httpsDir) + repo, err := IdentifyRepo(dir) require.NoError(t, err) - - assert.Equal(t, sshRepo.Identifier, httpsRepo.Identifier) + assert.Equal(t, "Acme/Widgets", repo.Identifier) } func TestIdentifyRepo_FromSubdirectory(t *testing.T) { @@ -86,7 +68,7 @@ func TestIdentifyRepo_FromSubdirectory(t *testing.T) { repo, err := IdentifyRepo(nested) require.NoError(t, err) - assert.Equal(t, "github.com/acme/widgets", repo.Identifier) + assert.Equal(t, "Acme/Widgets", repo.Identifier) assert.Equal(t, absPath(t, dir), absPath(t, repo.Root)) } @@ -138,7 +120,7 @@ func TestIdentifyRepo_StubNotARepo(t *testing.T) { func TestIdentifyRepo_StubNoOrigin(t *testing.T) { _, err := identifyRepo(stubGit{ cmds: map[string]string{"rev-parse --show-toplevel": "/repo"}, - errs: map[string]error{"remote get-url origin": errors.New("no such remote")}, + errs: map[string]error{"config --local --get remote.origin.url": errors.New("no such remote")}, }, "/repo") require.ErrorIs(t, err, ErrNoOrigin) } @@ -146,13 +128,13 @@ func TestIdentifyRepo_StubNoOrigin(t *testing.T) { func TestIdentifyRepo_StubOrigin(t *testing.T) { repo, err := identifyRepo(stubGit{ cmds: map[string]string{ - "rev-parse --show-toplevel": "/repo", - "remote get-url origin": "https://github.com/Acme/Widgets.git", + "rev-parse --show-toplevel": "/repo", + "config --local --get remote.origin.url": "https://github.com/Acme/Widgets.git", }, }, "/repo") require.NoError(t, err) assert.Equal(t, "/repo", repo.Root) - assert.Equal(t, "github.com/acme/widgets", repo.Identifier) + assert.Equal(t, "Acme/Widgets", repo.Identifier) } type stubGit struct { From ed87f9037635e00c81d841450e1e89022873e434 Mon Sep 17 00:00:00 2001 From: Clifford Tawiah Date: Fri, 11 Sep 2026 19:00:14 -0400 Subject: [PATCH 3/5] refactor(sync): derive typed Git source identity --- internal/sync/git.go | 85 ----------- internal/sync/git_test.go | 206 --------------------------- internal/sync/repository/git.go | 149 +++++++++++++++++++ internal/sync/repository/git_test.go | 135 ++++++++++++++++++ internal/sync/source.go | 49 +++++++ internal/sync/source_test.go | 48 +++++++ 6 files changed, 381 insertions(+), 291 deletions(-) delete mode 100644 internal/sync/git.go delete mode 100644 internal/sync/git_test.go create mode 100644 internal/sync/repository/git.go create mode 100644 internal/sync/repository/git_test.go create mode 100644 internal/sync/source.go create mode 100644 internal/sync/source_test.go diff --git a/internal/sync/git.go b/internal/sync/git.go deleted file mode 100644 index 250eb71a..00000000 --- a/internal/sync/git.go +++ /dev/null @@ -1,85 +0,0 @@ -package sync - -import ( - "errors" - "fmt" - "net/url" - "os/exec" - "strings" -) - -var ( - ErrGitNotInstalled = errors.New("git is not installed; install git and initialize a repository before continuing") - ErrNotGitRepo = errors.New("not a git repository; run git init before continuing") - ErrNoOrigin = errors.New("repository has no origin remote; add a remote named origin before continuing") -) - -type Repo struct { - Root string - Identifier string -} - -type gitRunner interface { - lookPath(name string) (string, error) - output(dir string, args ...string) (string, error) -} - -type execGit struct{} - -func (execGit) lookPath(name string) (string, error) { - return exec.LookPath(name) -} - -func (execGit) output(dir string, args ...string) (string, error) { - cmd := exec.Command("git", args...) - cmd.Dir = dir - out, err := cmd.Output() - if err != nil { - return "", err - } - - return strings.TrimSpace(string(out)), nil -} - -func IdentifyRepo(dir string) (Repo, error) { - return identifyRepo(execGit{}, dir) -} - -func identifyRepo(git gitRunner, dir string) (Repo, error) { - if _, err := git.lookPath("git"); err != nil { - return Repo{}, ErrGitNotInstalled - } - - root, err := git.output(dir, "rev-parse", "--show-toplevel") - if err != nil { - return Repo{}, ErrNotGitRepo - } - - origin, err := git.output(root, "config", "--local", "--get", "remote.origin.url") - if err != nil || origin == "" { - return Repo{}, ErrNoOrigin - } - - identifier, err := repoIdentifier(origin) - if err != nil { - return Repo{}, err - } - - return Repo{Root: root, Identifier: identifier}, nil -} - -func repoIdentifier(remote string) (string, error) { - repoPath := remote - if parsed, err := url.Parse(remote); err == nil && parsed.Host != "" { - repoPath = parsed.Path - } else if _, path, ok := strings.Cut(remote, ":"); ok { - repoPath = path - } - - parts := strings.Split(strings.TrimSuffix(strings.Trim(repoPath, "/"), ".git"), "/") - if len(parts) < 2 { - return "", fmt.Errorf("cannot derive repository identifier from origin %q", remote) - } - - return strings.Join(parts[len(parts)-2:], "/"), nil -} diff --git a/internal/sync/git_test.go b/internal/sync/git_test.go deleted file mode 100644 index 70c061ee..00000000 --- a/internal/sync/git_test.go +++ /dev/null @@ -1,206 +0,0 @@ -package sync - -import ( - "errors" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestRepoIdentifier(t *testing.T) { - tests := map[string]string{ - "git@github.com:launchdarkly/ldcli.git": "launchdarkly/ldcli", - "https://github.com/launchdarkly/ldcli.git": "launchdarkly/ldcli", - "ssh://git@github.com/launchdarkly/ldcli.git": "launchdarkly/ldcli", - } - - for remote, expected := range tests { - t.Run(remote, func(t *testing.T) { - actual, err := repoIdentifier(remote) - require.NoError(t, err) - assert.Equal(t, expected, actual) - }) - } -} - -func TestRepoIdentifier_InvalidOrigin(t *testing.T) { - _, err := repoIdentifier("ldcli") - require.Error(t, err) -} - -func TestIdentifyRepo(t *testing.T) { - requireGit(t) - - dir := initGitRepo(t) - runGit(t, dir, "remote", "add", "origin", "git@github.com:Acme/Widgets.git") - - repo, err := IdentifyRepo(dir) - require.NoError(t, err) - assert.Equal(t, "Acme/Widgets", repo.Identifier) - assert.Equal(t, absPath(t, dir), absPath(t, repo.Root)) -} - -func TestIdentifyRepo_DoesNotExpandInsteadOf(t *testing.T) { - requireGit(t) - - dir := initGitRepo(t) - runGit(t, dir, "remote", "add", "origin", "git@github.com:Acme/Widgets.git") - runGit(t, dir, "config", "--local", "url.ssh://git@github.com:443/.insteadOf", "git@github.com:") - - repo, err := IdentifyRepo(dir) - require.NoError(t, err) - assert.Equal(t, "Acme/Widgets", repo.Identifier) -} - -func TestIdentifyRepo_FromSubdirectory(t *testing.T) { - requireGit(t) - - dir := initGitRepo(t) - runGit(t, dir, "remote", "add", "origin", "https://github.com/Acme/Widgets.git") - - nested := filepath.Join(dir, "apps", "api") - require.NoError(t, os.MkdirAll(nested, 0o755)) - - repo, err := IdentifyRepo(nested) - require.NoError(t, err) - assert.Equal(t, "Acme/Widgets", repo.Identifier) - assert.Equal(t, absPath(t, dir), absPath(t, repo.Root)) -} - -func TestIdentifyRepo_StableAcrossBranches(t *testing.T) { - requireGit(t) - - dir := initGitRepo(t) - runGit(t, dir, "remote", "add", "origin", "https://github.com/Acme/Widgets.git") - require.NoError(t, os.WriteFile(filepath.Join(dir, "README"), []byte("x\n"), 0o644)) - runGit(t, dir, "add", "README") - runGit(t, dir, "commit", "--quiet", "-m", "init") - - main, err := IdentifyRepo(dir) - require.NoError(t, err) - - runGit(t, dir, "checkout", "-b", "feature") - - feature, err := IdentifyRepo(dir) - require.NoError(t, err) - assert.Equal(t, main.Identifier, feature.Identifier) -} - -func TestIdentifyRepo_NotARepo(t *testing.T) { - requireGit(t) - - _, err := IdentifyRepo(t.TempDir()) - require.ErrorIs(t, err, ErrNotGitRepo) -} - -func TestIdentifyRepo_NoOrigin(t *testing.T) { - requireGit(t) - - _, err := IdentifyRepo(initGitRepo(t)) - require.ErrorIs(t, err, ErrNoOrigin) -} - -func TestIdentifyRepo_GitNotInstalled(t *testing.T) { - _, err := identifyRepo(stubGit{pathErr: errors.New("not found")}, t.TempDir()) - require.ErrorIs(t, err, ErrGitNotInstalled) -} - -func TestIdentifyRepo_StubNotARepo(t *testing.T) { - _, err := identifyRepo(stubGit{ - errs: map[string]error{"rev-parse --show-toplevel": errors.New("fatal")}, - }, "/tmp/proj") - require.ErrorIs(t, err, ErrNotGitRepo) -} - -func TestIdentifyRepo_StubNoOrigin(t *testing.T) { - _, err := identifyRepo(stubGit{ - cmds: map[string]string{"rev-parse --show-toplevel": "/repo"}, - errs: map[string]error{"config --local --get remote.origin.url": errors.New("no such remote")}, - }, "/repo") - require.ErrorIs(t, err, ErrNoOrigin) -} - -func TestIdentifyRepo_StubOrigin(t *testing.T) { - repo, err := identifyRepo(stubGit{ - cmds: map[string]string{ - "rev-parse --show-toplevel": "/repo", - "config --local --get remote.origin.url": "https://github.com/Acme/Widgets.git", - }, - }, "/repo") - require.NoError(t, err) - assert.Equal(t, "/repo", repo.Root) - assert.Equal(t, "Acme/Widgets", repo.Identifier) -} - -type stubGit struct { - pathErr error - cmds map[string]string - errs map[string]error -} - -func (s stubGit) lookPath(string) (string, error) { - if s.pathErr != nil { - return "", s.pathErr - } - - return "/usr/bin/git", nil -} - -func (s stubGit) output(_ string, args ...string) (string, error) { - key := strings.Join(args, " ") - if s.errs != nil { - if err, ok := s.errs[key]; ok { - return "", err - } - } - if s.cmds != nil { - if out, ok := s.cmds[key]; ok { - return out, nil - } - } - - return "", errors.New("unexpected git " + key) -} - -func requireGit(t *testing.T) { - t.Helper() - if _, err := exec.LookPath("git"); err != nil { - t.Skip("git not installed") - } -} - -func initGitRepo(t *testing.T) string { - t.Helper() - dir := t.TempDir() - runGit(t, dir, "init", "--quiet") - - return dir -} - -func runGit(t *testing.T, dir string, args ...string) { - t.Helper() - cmd := exec.Command("git", append([]string{ - "-c", "user.name=ldcli", - "-c", "user.email=ldcli@example.com", - }, args...)...) - cmd.Dir = dir - cmd.Env = append(os.Environ(), - "GIT_CONFIG_GLOBAL="+os.DevNull, - "GIT_CONFIG_NOSYSTEM=1", - ) - out, err := cmd.CombinedOutput() - require.NoError(t, err, string(out)) -} - -func absPath(t *testing.T, dir string) string { - t.Helper() - resolved, err := filepath.EvalSymlinks(dir) - require.NoError(t, err) - - return resolved -} diff --git a/internal/sync/repository/git.go b/internal/sync/repository/git.go new file mode 100644 index 00000000..ccdc824f --- /dev/null +++ b/internal/sync/repository/git.go @@ -0,0 +1,149 @@ +package repository + +import ( + "fmt" + "net" + "net/url" + "os/exec" + "strings" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" +) + +type GitRepository struct { + Root string + Source syncdomain.Source +} + +type gitRunner interface { + lookPath(name string) (string, error) + output(dir string, args ...string) (string, error) +} + +type execGit struct{} + +func (execGit) lookPath(name string) (string, error) { + return exec.LookPath(name) +} + +func (execGit) output(dir string, args ...string) (string, error) { + cmd := exec.Command("git", args...) + cmd.Dir = dir + out, err := cmd.Output() + if err != nil { + return "", err + } + + return strings.TrimSpace(string(out)), nil +} + +func FindGitSource(dir string) (GitRepository, bool, error) { + return findGitSource(execGit{}, dir) +} + +func findGitSource(git gitRunner, dir string) (GitRepository, bool, error) { + if _, err := git.lookPath("git"); err != nil { + return GitRepository{}, false, nil + } + + root, err := git.output(dir, "rev-parse", "--show-toplevel") + if err != nil { + return GitRepository{}, false, nil + } + + origin, err := git.output(root, "config", "--local", "--get", "remote.origin.url") + if err != nil || origin == "" { + return GitRepository{}, false, nil + } + + identifier, err := gitSourceIdentifier(origin) + if err != nil { + return GitRepository{}, false, err + } + + source, err := syncdomain.NewSource(syncdomain.SourceTypeGit, identifier) + if err != nil { + return GitRepository{}, false, err + } + + return GitRepository{Root: root, Source: source}, true, nil +} + +func gitSourceIdentifier(remote string) (string, error) { + remote = strings.TrimSpace(remote) + + var host, repoPath string + + if strings.Contains(remote, "://") { + parsed, err := url.Parse(remote) + if err != nil || parsed.Host == "" { + return "", invalidGitRemote(remote) + } + + host = normalizedURLHost(parsed) + repoPath = parsed.Path + } else { + remoteHost, remotePath, ok := strings.Cut(remote, ":") + if !ok { + return "", invalidGitRemote(remote) + } + + if _, value, ok := strings.Cut(remoteHost, "@"); ok { + remoteHost = value + } + + host = strings.ToLower(strings.TrimSpace(remoteHost)) + repoPath = remotePath + } + + repoPath = strings.TrimSuffix(strings.Trim(repoPath, "/"), ".git") + if host == "" || !validRepositoryPath(repoPath) { + return "", invalidGitRemote(remote) + } + + return host + "/" + repoPath, nil +} + +func normalizedURLHost(remote *url.URL) string { + host := strings.ToLower(remote.Hostname()) + port := remote.Port() + if port == "" || isDefaultPort(remote.Scheme, port) { + return host + } + + return net.JoinHostPort(host, port) +} + +func isDefaultPort(scheme, port string) bool { + switch strings.ToLower(scheme) { + case "http": + return port == "80" + case "https": + return port == "443" + case "ssh": + return port == "22" + case "git": + return port == "9418" + default: + return false + } +} + +func validRepositoryPath(repoPath string) bool { + parts := strings.Split(repoPath, "/") + if len(parts) < 2 { + return false + } + + for _, part := range parts { + if part == "" || part == "." || part == ".." { + return false + } + } + + return true +} + +func invalidGitRemote(remote string) error { + return fmt.Errorf("cannot derive source identifier from Git origin %q", remote) +} diff --git a/internal/sync/repository/git_test.go b/internal/sync/repository/git_test.go new file mode 100644 index 00000000..ba8e7131 --- /dev/null +++ b/internal/sync/repository/git_test.go @@ -0,0 +1,135 @@ +package repository + +import ( + "errors" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" +) + +func TestGitSourceIdentifier(t *testing.T) { + tests := map[string]string{ + "git@github.com:launchdarkly/ldcli.git": "github.com/launchdarkly/ldcli", + "https://github.com/launchdarkly/ldcli.git": "github.com/launchdarkly/ldcli", + "https://github.com:443/launchdarkly/ldcli.git": "github.com/launchdarkly/ldcli", + "ssh://git@github.com/launchdarkly/ldcli.git": "github.com/launchdarkly/ldcli", + "ssh://git@git.example.com:2222/platform/team/service": "git.example.com:2222/platform/team/service", + } + + for remote, expected := range tests { + t.Run(remote, func(t *testing.T) { + actual, err := gitSourceIdentifier(remote) + require.NoError(t, err) + assert.Equal(t, expected, actual) + }) + } +} + +func TestGitSourceIdentifierRejectsInvalidRemote(t *testing.T) { + for _, remote := range []string{ + "", + "ldcli", + "file:///workspace/launchdarkly/ldcli", + "https://github.com/ldcli", + "https://github.com/org/../repo", + } { + t.Run(remote, func(t *testing.T) { + _, err := gitSourceIdentifier(remote) + require.Error(t, err) + }) + } +} + +func TestFindGitSource(t *testing.T) { + repository, found, err := findGitSource(stubGit{ + commands: map[string]string{ + "rev-parse --show-toplevel": "/workspace", + "config --local --get remote.origin.url": "git@github.com:Acme/Widgets.git", + }, + }, "/workspace/service") + + require.NoError(t, err) + require.True(t, found) + assert.Equal(t, "/workspace", repository.Root) + assert.Equal(t, syncdomain.SourceTypeGit, repository.Source.Type()) + assert.Equal(t, "github.com/Acme/Widgets", repository.Source.Identifier()) +} + +func TestFindGitSourceFallsBackWhenGitIdentityIsUnavailable(t *testing.T) { + tests := []struct { + name string + git stubGit + }{ + { + name: "git is not installed", + git: stubGit{pathErr: errors.New("not found")}, + }, + { + name: "workspace is not a repository", + git: stubGit{ + errors: map[string]error{"rev-parse --show-toplevel": errors.New("not a repository")}, + }, + }, + { + name: "repository has no origin", + git: stubGit{ + commands: map[string]string{"rev-parse --show-toplevel": "/workspace"}, + errors: map[string]error{ + "config --local --get remote.origin.url": errors.New("missing"), + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + repository, found, err := findGitSource(test.git, "/workspace") + + require.NoError(t, err) + assert.False(t, found) + assert.Empty(t, repository) + }) + } +} + +func TestFindGitSourceRejectsInvalidOrigin(t *testing.T) { + _, found, err := findGitSource(stubGit{ + commands: map[string]string{ + "rev-parse --show-toplevel": "/workspace", + "config --local --get remote.origin.url": "invalid", + }, + }, "/workspace") + + require.Error(t, err) + assert.False(t, found) +} + +type stubGit struct { + pathErr error + commands map[string]string + errors map[string]error +} + +func (s stubGit) lookPath(string) (string, error) { + if s.pathErr != nil { + return "", s.pathErr + } + + return "/usr/bin/git", nil +} + +func (s stubGit) output(_ string, args ...string) (string, error) { + key := strings.Join(args, " ") + if err, ok := s.errors[key]; ok { + return "", err + } + if output, ok := s.commands[key]; ok { + return output, nil + } + + return "", errors.New("unexpected git " + key) +} diff --git a/internal/sync/source.go b/internal/sync/source.go new file mode 100644 index 00000000..cc3dea7f --- /dev/null +++ b/internal/sync/source.go @@ -0,0 +1,49 @@ +package sync + +import ( + "errors" + "strings" +) + +const MaxSourceIdentifierLength = 512 + +type SourceType string + +const ( + SourceTypeGit SourceType = "git" + SourceTypeLocal SourceType = "local" +) + +var ( + ErrInvalidSourceType = errors.New("invalid source type") + ErrInvalidSourceIdentifier = errors.New("invalid source identifier") +) + +type Source struct { + sourceType SourceType + identifier string +} + +func NewSource(sourceType SourceType, identifier string) (Source, error) { + if sourceType != SourceTypeGit && sourceType != SourceTypeLocal { + return Source{}, ErrInvalidSourceType + } + if identifier == "" || + identifier != strings.TrimSpace(identifier) || + len(identifier) > MaxSourceIdentifierLength { + return Source{}, ErrInvalidSourceIdentifier + } + + return Source{ + sourceType: sourceType, + identifier: identifier, + }, nil +} + +func (source Source) Type() SourceType { + return source.sourceType +} + +func (source Source) Identifier() string { + return source.identifier +} diff --git a/internal/sync/source_test.go b/internal/sync/source_test.go new file mode 100644 index 00000000..7ed011a8 --- /dev/null +++ b/internal/sync/source_test.go @@ -0,0 +1,48 @@ +package sync + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewSource(t *testing.T) { + for _, sourceType := range []SourceType{SourceTypeGit, SourceTypeLocal} { + t.Run(string(sourceType), func(t *testing.T) { + source, err := NewSource(sourceType, "source-identifier") + + require.NoError(t, err) + assert.Equal(t, sourceType, source.Type()) + assert.Equal(t, "source-identifier", source.Identifier()) + }) + } +} + +func TestNewSourceRejectsInvalidValues(t *testing.T) { + tests := []struct { + name string + sourceType SourceType + identifier string + wantErr error + }{ + {"invalid type", "filesystem", "local/project", ErrInvalidSourceType}, + {"missing identifier", SourceTypeGit, "", ErrInvalidSourceIdentifier}, + {"blank identifier", SourceTypeGit, " ", ErrInvalidSourceIdentifier}, + {"surrounding whitespace", SourceTypeGit, " source ", ErrInvalidSourceIdentifier}, + { + "identifier too long", + SourceTypeGit, + strings.Repeat("a", MaxSourceIdentifierLength+1), + ErrInvalidSourceIdentifier, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := NewSource(test.sourceType, test.identifier) + assert.ErrorIs(t, err, test.wantErr) + }) + } +} From 613633d5067a9455b47736dee37b519815ff5d4f Mon Sep 17 00:00:00 2001 From: Clifford Tawiah Date: Fri, 11 Sep 2026 19:02:37 -0400 Subject: [PATCH 4/5] feat(sync): persist ldcli installation identity --- internal/config/config.go | 1 + internal/config/installation_id.go | 120 ++++++++++++++++++++++++ internal/config/installation_id_test.go | 89 ++++++++++++++++++ 3 files changed, 210 insertions(+) create mode 100644 internal/config/installation_id.go create mode 100644 internal/config/installation_id_test.go diff --git a/internal/config/config.go b/internal/config/config.go index b6f72004..ec957412 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -26,6 +26,7 @@ type Config struct { DevStreamURI string `json:"dev-stream-uri,omitempty" yaml:"dev-stream-uri,omitempty"` Environment string `json:"environment,omitempty" yaml:"environment,omitempty"` Flag string `json:"flag,omitempty" yaml:"flag,omitempty"` + InstallationID string `json:"-" yaml:"installation-id,omitempty"` Output string `json:"output,omitempty" yaml:"output,omitempty"` Project string `json:"project,omitempty" yaml:"project,omitempty"` } diff --git a/internal/config/installation_id.go b/internal/config/installation_id.go new file mode 100644 index 00000000..7f20e613 --- /dev/null +++ b/internal/config/installation_id.go @@ -0,0 +1,120 @@ +package config + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/google/uuid" + "gopkg.in/yaml.v3" +) + +const installationIDKey = "installation-id" + +func EnsureInstallationID(filename string) (string, error) { + if strings.TrimSpace(filename) == "" { + return "", errors.New("config filename is required") + } + + values, mode, err := readConfigValues(filename) + if err != nil { + return "", err + } + + if value, ok := values[installationIDKey]; ok { + installationID, ok := value.(string) + if !ok || uuid.Validate(installationID) != nil { + return "", errors.New("ldcli installation ID in config is invalid") + } + + return installationID, nil + } + + installationID := uuid.NewString() + values[installationIDKey] = installationID + + if err := writeConfigValues(filename, values, mode); err != nil { + return "", err + } + + return installationID, nil +} + +func readConfigValues(filename string) (map[string]any, os.FileMode, error) { + data, err := os.ReadFile(filename) + if errors.Is(err, os.ErrNotExist) { + return map[string]any{}, 0o600, nil + } + if err != nil { + return nil, 0, fmt.Errorf("read ldcli config: %w", err) + } + + values := map[string]any{} + if err := yaml.Unmarshal(data, &values); err != nil { + return nil, 0, fmt.Errorf("parse ldcli config: %w", err) + } + + info, err := os.Stat(filename) + if err != nil { + return nil, 0, fmt.Errorf("stat ldcli config: %w", err) + } + + mode := info.Mode().Perm() + if mode == 0 { + mode = 0o600 + } + + return values, mode, nil +} + +func writeConfigValues( + filename string, + values map[string]any, + mode os.FileMode, +) error { + data, err := yaml.Marshal(values) + if err != nil { + return fmt.Errorf("marshal ldcli config: %w", err) + } + + dir := filepath.Dir(filename) + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("create ldcli config directory: %w", err) + } + + file, err := os.CreateTemp(dir, ".config-*.tmp") + if err != nil { + return fmt.Errorf("create temporary ldcli config: %w", err) + } + + tempName := file.Name() + defer func() { + _ = os.Remove(tempName) + }() + + if err := file.Chmod(mode); err != nil { + _ = file.Close() + + return fmt.Errorf("set ldcli config permissions: %w", err) + } + if _, err := file.Write(data); err != nil { + _ = file.Close() + + return fmt.Errorf("write ldcli config: %w", err) + } + if err := file.Sync(); err != nil { + _ = file.Close() + + return fmt.Errorf("sync ldcli config: %w", err) + } + if err := file.Close(); err != nil { + return fmt.Errorf("close ldcli config: %w", err) + } + if err := os.Rename(tempName, filename); err != nil { + return fmt.Errorf("replace ldcli config: %w", err) + } + + return nil +} diff --git a/internal/config/installation_id_test.go b/internal/config/installation_id_test.go new file mode 100644 index 00000000..6f1aea57 --- /dev/null +++ b/internal/config/installation_id_test.go @@ -0,0 +1,89 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func TestEnsureInstallationIDCreatesConfig(t *testing.T) { + filename := filepath.Join(t.TempDir(), "ldcli", "config.yml") + + installationID, err := EnsureInstallationID(filename) + + require.NoError(t, err) + require.NoError(t, uuid.Validate(installationID)) + + loaded, err := New(filename, os.ReadFile) + require.NoError(t, err) + assert.Equal(t, installationID, loaded.InstallationID) + + info, err := os.Stat(filename) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) +} + +func TestEnsureInstallationIDIsStableAndPreservesConfig(t *testing.T) { + filename := filepath.Join(t.TempDir(), "config.yml") + require.NoError(t, os.WriteFile( + filename, + []byte("access-token: token\nfuture-setting: value\n"), + 0o640, + )) + + first, err := EnsureInstallationID(filename) + require.NoError(t, err) + second, err := EnsureInstallationID(filename) + require.NoError(t, err) + assert.Equal(t, first, second) + + data, err := os.ReadFile(filename) + require.NoError(t, err) + + var values map[string]any + require.NoError(t, yaml.Unmarshal(data, &values)) + assert.Equal(t, "token", values["access-token"]) + assert.Equal(t, "value", values["future-setting"]) + assert.Equal(t, first, values[installationIDKey]) + + info, err := os.Stat(filename) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o640), info.Mode().Perm()) +} + +func TestEnsureInstallationIDUsesExistingValueWithoutRewriting(t *testing.T) { + filename := filepath.Join(t.TempDir(), "config.yml") + const existing = "installation-id: 45cb6eca-6c83-4db6-b171-174fd2fed588\n" + require.NoError(t, os.WriteFile(filename, []byte(existing), 0o600)) + + installationID, err := EnsureInstallationID(filename) + + require.NoError(t, err) + assert.Equal(t, "45cb6eca-6c83-4db6-b171-174fd2fed588", installationID) + + data, err := os.ReadFile(filename) + require.NoError(t, err) + assert.Equal(t, existing, string(data)) +} + +func TestEnsureInstallationIDRejectsInvalidValue(t *testing.T) { + filename := filepath.Join(t.TempDir(), "config.yml") + require.NoError(t, os.WriteFile(filename, []byte("installation-id: invalid\n"), 0o600)) + + _, err := EnsureInstallationID(filename) + + require.ErrorContains(t, err, "installation ID") +} + +func TestConfigJSONDoesNotExposeInstallationID(t *testing.T) { + data, err := json.Marshal(Config{InstallationID: uuid.NewString()}) + + require.NoError(t, err) + assert.JSONEq(t, `{}`, string(data)) +} From fcd6a4304dbcb1a74fe1c981472e93430e3c896d Mon Sep 17 00:00:00 2001 From: Clifford Tawiah Date: Fri, 11 Sep 2026 19:04:12 -0400 Subject: [PATCH 5/5] 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 +}