Skip to content
Merged
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
78 changes: 59 additions & 19 deletions pkg/code-manager/clone.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,35 +92,75 @@ func (c *realCodeManager) normalizeRepositoryURL(repoURL string) (string, error)
// Remove .git suffix if present
normalized := strings.TrimSuffix(repoURL, ".git")

// Handle SSH URLs (git@host:user/repo) first
if strings.Contains(normalized, "@") && strings.Contains(normalized, ":") && !strings.HasPrefix(normalized, "http") {
parts := strings.Split(normalized, ":")
if len(parts) == 2 {
hostParts := strings.Split(parts[0], "@")
if len(hostParts) == 2 {
host := hostParts[1]
path := parts[1]
return host + "/" + path, nil
}
}
// Handle ssh:// URLs first (before other SSH format checks)
if strings.HasPrefix(normalized, "ssh://") {
return c.normalizeSSHProtocolURL(normalized)
}

// Handle SSH URLs (git@host:user/repo)
if c.isSSHURL(normalized) {
return c.normalizeSSHURL(normalized)
}

// Handle HTTPS URLs
if strings.HasPrefix(normalized, "http") {
parsedURL, err := url.Parse(normalized)
if err != nil {
return "", fmt.Errorf("invalid repository URL: %w", err)
}

host := parsedURL.Host
path := strings.TrimPrefix(parsedURL.Path, "/")
return host + "/" + path, nil
return c.normalizeHTTPSURL(normalized)
}

// If we get here, the URL format is not supported
return "", fmt.Errorf("%w: %s", ErrUnsupportedRepositoryURLFormat, repoURL)
}

// normalizeSSHProtocolURL normalizes ssh:// URLs to host/path format.
func (c *realCodeManager) normalizeSSHProtocolURL(normalized string) (string, error) {
parsedURL, err := url.Parse(normalized)
if err != nil {
return "", fmt.Errorf("invalid repository URL: %w", err)
}

host := parsedURL.Host
// Remove port if present (e.g., host:22 -> host)
if colonIdx := strings.Index(host, ":"); colonIdx != -1 {
host = host[:colonIdx]
}
path := strings.TrimPrefix(parsedURL.Path, "/")
return host + "/" + path, nil
}

// isSSHURL checks if the URL is in git@host:path format.
func (c *realCodeManager) isSSHURL(normalized string) bool {
return strings.Contains(normalized, "@") && strings.Contains(normalized, ":") && !strings.HasPrefix(normalized, "http")
}

// normalizeSSHURL normalizes git@host:path URLs to host/path format.
func (c *realCodeManager) normalizeSSHURL(normalized string) (string, error) {
parts := strings.Split(normalized, ":")
if len(parts) != 2 {
return "", fmt.Errorf("%w: %s", ErrUnsupportedRepositoryURLFormat, normalized)
}

hostParts := strings.Split(parts[0], "@")
if len(hostParts) != 2 {
return "", fmt.Errorf("%w: %s", ErrUnsupportedRepositoryURLFormat, normalized)
}

host := hostParts[1]
path := parts[1]
return host + "/" + path, nil
}

// normalizeHTTPSURL normalizes https:// URLs to host/path format.
func (c *realCodeManager) normalizeHTTPSURL(normalized string) (string, error) {
parsedURL, err := url.Parse(normalized)
if err != nil {
return "", fmt.Errorf("invalid repository URL: %w", err)
}

host := parsedURL.Host
path := strings.TrimPrefix(parsedURL.Path, "/")
return host + "/" + path, nil
}

// checkRepositoryExists checks if a repository already exists in the status file.
func (c *realCodeManager) checkRepositoryExists(normalizedURL string) error {
repos, err := c.deps.StatusManager.ListRepositories()
Expand Down
207 changes: 207 additions & 0 deletions pkg/code-manager/clone_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -392,3 +392,210 @@ func TestRealCM_Clone_InitializationFailure(t *testing.T) {
assert.Error(t, err)
assert.ErrorIs(t, err, ErrFailedToInitializeRepository)
}

func TestRealCM_Clone_SSHProtocolURL(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()

mockFS := fsmocks.NewMockFS(ctrl)
mockGit := gitmocks.NewMockGit(ctrl)
mockStatus := statusmocks.NewMockManager(ctrl)
mockRepository := repositorymocks.NewMockRepository(ctrl)
mockWorkspace := workspacemocks.NewMockWorkspace(ctrl)
mockConfig := configmocks.NewMockManager(ctrl)

cm, err := NewCodeManager(NewCodeManagerParams{
Dependencies: dependencies.New().
WithRepositoryProvider(func(params repository.NewRepositoryParams) repository.Repository {
return mockRepository
}).
WithWorkspaceProvider(func(params workspace.NewWorkspaceParams) workspace.Workspace {
return mockWorkspace
}).
WithConfig(mockConfig).
WithFS(mockFS).
WithGit(mockGit).
WithStatusManager(mockStatus),
})
assert.NoError(t, err)

repoURL := "ssh://git@forge.lab.home.lerenn.net/homelab/lgtm.git"
normalizedURL := "forge.lab.home.lerenn.net/homelab/lgtm"
defaultBranch := "main"
targetPath := "/test/base/path/forge.lab.home.lerenn.net/homelab/lgtm/origin/main"

// Mock config manager
testConfig := config.Config{
RepositoriesDir: "/test/base/path",
WorkspacesDir: "/test/workspaces",
StatusFile: "/test/status.yaml",
}
mockConfig.EXPECT().GetConfigWithFallback().Return(testConfig, nil).AnyTimes()

// Mock repository existence check
mockStatus.EXPECT().ListRepositories().Return(map[string]status.Repository{}, nil)

// Mock default branch detection
mockGit.EXPECT().GetDefaultBranch(repoURL).Return(defaultBranch, nil)

// Mock directory creation
mockFS.EXPECT().MkdirAll("/test/base/path/forge.lab.home.lerenn.net/homelab/lgtm/origin", gomock.Any()).Return(nil)

// Mock clone operation
mockGit.EXPECT().Clone(git.CloneParams{
RepoURL: repoURL,
TargetPath: targetPath,
Recursive: true,
}).Return(nil)

// Mock repository initialization
mockStatus.EXPECT().AddRepository(normalizedURL, status.AddRepositoryParams{
Path: targetPath,
Remotes: map[string]status.Remote{
"origin": {
DefaultBranch: defaultBranch,
},
},
}).Return(nil)

err = cm.Clone(repoURL)
assert.NoError(t, err)
}

func TestRealCM_Clone_SSHProtocolURLWithoutDotGit(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()

mockFS := fsmocks.NewMockFS(ctrl)
mockGit := gitmocks.NewMockGit(ctrl)
mockStatus := statusmocks.NewMockManager(ctrl)
mockRepository := repositorymocks.NewMockRepository(ctrl)
mockWorkspace := workspacemocks.NewMockWorkspace(ctrl)
mockConfig := configmocks.NewMockManager(ctrl)

cm, err := NewCodeManager(NewCodeManagerParams{
Dependencies: dependencies.New().
WithRepositoryProvider(func(params repository.NewRepositoryParams) repository.Repository {
return mockRepository
}).
WithWorkspaceProvider(func(params workspace.NewWorkspaceParams) workspace.Workspace {
return mockWorkspace
}).
WithConfig(mockConfig).
WithFS(mockFS).
WithGit(mockGit).
WithStatusManager(mockStatus),
})
assert.NoError(t, err)

repoURL := "ssh://git@forge.lab.home.lerenn.net/homelab/lgtm"
normalizedURL := "forge.lab.home.lerenn.net/homelab/lgtm"
defaultBranch := "main"
targetPath := "/test/base/path/forge.lab.home.lerenn.net/homelab/lgtm/origin/main"

// Mock config manager
testConfig := config.Config{
RepositoriesDir: "/test/base/path",
WorkspacesDir: "/test/workspaces",
StatusFile: "/test/status.yaml",
}
mockConfig.EXPECT().GetConfigWithFallback().Return(testConfig, nil).AnyTimes()

// Mock repository existence check
mockStatus.EXPECT().ListRepositories().Return(map[string]status.Repository{}, nil)

// Mock default branch detection
mockGit.EXPECT().GetDefaultBranch(repoURL).Return(defaultBranch, nil)

// Mock directory creation
mockFS.EXPECT().MkdirAll("/test/base/path/forge.lab.home.lerenn.net/homelab/lgtm/origin", gomock.Any()).Return(nil)

// Mock clone operation
mockGit.EXPECT().Clone(git.CloneParams{
RepoURL: repoURL,
TargetPath: targetPath,
Recursive: true,
}).Return(nil)

// Mock repository initialization
mockStatus.EXPECT().AddRepository(normalizedURL, status.AddRepositoryParams{
Path: targetPath,
Remotes: map[string]status.Remote{
"origin": {
DefaultBranch: defaultBranch,
},
},
}).Return(nil)

err = cm.Clone(repoURL)
assert.NoError(t, err)
}

func TestRealCM_Clone_SSHProtocolURLWithPort(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()

mockFS := fsmocks.NewMockFS(ctrl)
mockGit := gitmocks.NewMockGit(ctrl)
mockStatus := statusmocks.NewMockManager(ctrl)
mockRepository := repositorymocks.NewMockRepository(ctrl)
mockWorkspace := workspacemocks.NewMockWorkspace(ctrl)
mockConfig := configmocks.NewMockManager(ctrl)

cm, err := NewCodeManager(NewCodeManagerParams{
Dependencies: dependencies.New().
WithRepositoryProvider(func(params repository.NewRepositoryParams) repository.Repository {
return mockRepository
}).
WithWorkspaceProvider(func(params workspace.NewWorkspaceParams) workspace.Workspace {
return mockWorkspace
}).
WithConfig(mockConfig).
WithFS(mockFS).
WithGit(mockGit).
WithStatusManager(mockStatus),
})
assert.NoError(t, err)

repoURL := "ssh://git@forge.lab.home.lerenn.net:22/homelab/lgtm.git"
normalizedURL := "forge.lab.home.lerenn.net/homelab/lgtm"
defaultBranch := "main"
targetPath := "/test/base/path/forge.lab.home.lerenn.net/homelab/lgtm/origin/main"

// Mock config manager
testConfig := config.Config{
RepositoriesDir: "/test/base/path",
WorkspacesDir: "/test/workspaces",
StatusFile: "/test/status.yaml",
}
mockConfig.EXPECT().GetConfigWithFallback().Return(testConfig, nil).AnyTimes()

// Mock repository existence check
mockStatus.EXPECT().ListRepositories().Return(map[string]status.Repository{}, nil)

// Mock default branch detection
mockGit.EXPECT().GetDefaultBranch(repoURL).Return(defaultBranch, nil)

// Mock directory creation
mockFS.EXPECT().MkdirAll("/test/base/path/forge.lab.home.lerenn.net/homelab/lgtm/origin", gomock.Any()).Return(nil)

// Mock clone operation
mockGit.EXPECT().Clone(git.CloneParams{
RepoURL: repoURL,
TargetPath: targetPath,
Recursive: true,
}).Return(nil)

// Mock repository initialization
mockStatus.EXPECT().AddRepository(normalizedURL, status.AddRepositoryParams{
Path: targetPath,
Remotes: map[string]status.Remote{
"origin": {
DefaultBranch: defaultBranch,
},
},
}).Return(nil)

err = cm.Clone(repoURL)
assert.NoError(t, err)
}
32 changes: 32 additions & 0 deletions test/repo_clone_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -310,3 +310,35 @@ func TestCloneRepositoryRepoModeSSHURL(t *testing.T) {
require.Len(t, status.Repositories, 1, "Should have one repository entry")
}
}

// TestCloneRepositoryRepoModeSSHProtocolURL tests cloning with ssh:// URL format (if SSH is available)
func TestCloneRepositoryRepoModeSSHProtocolURL(t *testing.T) {
setup := setupTestEnvironment(t)
defer cleanupTestEnvironment(t, setup)

// Test ssh:// URL format (this will likely fail in CI environments without SSH keys)
// but it's good to test the URL parsing logic
repoURL := "ssh://git@github.com/octocat/Hello-World.git"

err := cloneRepository(t, setup, repoURL, true)
// This might fail due to SSH authentication, but the URL parsing should work
if err != nil {
// If it fails due to SSH auth, that's expected in test environments
// Check for either "ssh" or "unsupported" error (in case URL parsing fails)
assert.True(t, strings.Contains(err.Error(), "ssh") || strings.Contains(err.Error(), "unsupported"),
"Should fail due to SSH authentication or unsupported format, got: %s", err.Error())
} else {
// If it succeeds, verify the repository was cloned correctly
status := readStatusFile(t, setup.StatusPath)
require.NotNil(t, status.Repositories, "Status file should have repositories section")
require.Len(t, status.Repositories, 1, "Should have one repository entry")

// Verify the normalized URL is correct
var normalizedURL string
for url := range status.Repositories {
normalizedURL = url
break
}
assert.Equal(t, "github.com/octocat/Hello-World", normalizedURL, "Normalized URL should be correct")
}
}