diff --git a/integration-tests/environment_select_test.go b/integration-tests/environment_select_test.go new file mode 100644 index 00000000..e198640d --- /dev/null +++ b/integration-tests/environment_select_test.go @@ -0,0 +1,190 @@ +package tests + +import ( + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/upsun/cli/pkg/mockapi" +) + +// layoutProjectID is a 12+ character lowercase ID so it matches the Git URL +// detection pattern (which requires [a-z0-9]{12,}). +const layoutProjectID = "abcdefghijkl" + +// layoutGitURL is a project Git URL the CLI can parse to detect the project. +// The test config's detection.git_domain is "git.cli-tests.example.com" and the +// parser requires a leading "git." host label, hence the doubled "git.". +const layoutGitURL = layoutProjectID + "@git.git.cli-tests.example.com:" + layoutProjectID + ".git" + +func runGit(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + require.NoError(t, err, "git %v failed: %s", args, out) +} + +func initRepoOnBranch(t *testing.T, dir, branch string) { + t.Helper() + runGit(t, dir, "init", "--quiet", "--initial-branch="+branch) + runGit(t, dir, "config", "user.email", "test@example.com") + runGit(t, dir, "config", "user.name", "Test") + runGit(t, dir, "config", "commit.gpgsign", "false") + require.NoError(t, os.WriteFile(filepath.Join(dir, "README.md"), []byte("test\n"), 0o644)) + runGit(t, dir, "add", "-A") + runGit(t, dir, "commit", "--quiet", "-m", "Initial commit") +} + +func writeLayoutProjectConfig(t *testing.T, dir string) { + t.Helper() + configDir := filepath.Join(dir, ".platform", "local") + require.NoError(t, os.MkdirAll(configDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(configDir, "project.yaml"), []byte("id: "+layoutProjectID+"\n"), 0o644)) +} + +// repoLayout describes a local checkout layout to exercise project and +// environment auto-detection. +type repoLayout struct { + git bool // initialize a Git repository + branch string // branch to check out (Git repositories only) + remoteName string // add a remote with this name pointing at layoutGitURL ("" for none) + projectYAML bool // write .platform/local/project.yaml with the project ID + worktree bool // check out branch in a worktree nested inside a parent on the default branch +} + +// build creates the layout in a temporary directory and returns the directory +// the CLI should run in. +func (l repoLayout) build(t *testing.T) string { + base := t.TempDir() + + if !l.git { + if l.projectYAML { + writeLayoutProjectConfig(t, base) + } + return base + } + + if l.worktree { + // The parent stays on the default branch; the worktree checks out l.branch. + initRepoOnBranch(t, base, "main") + if l.remoteName != "" { + runGit(t, base, "remote", "add", l.remoteName, layoutGitURL) + } + worktree := filepath.Join(base, "nested", "worktree") + runGit(t, base, "worktree", "add", "-b", l.branch, worktree) + if l.projectYAML { + writeLayoutProjectConfig(t, worktree) + } + return worktree + } + + initRepoOnBranch(t, base, l.branch) + if l.remoteName != "" { + runGit(t, base, "remote", "add", l.remoteName, layoutGitURL) + } + if l.projectYAML { + writeLayoutProjectConfig(t, base) + } + return base +} + +// TestEnvironmentSelectionByLayout characterizes which environment the CLI +// auto-selects for a range of local checkout layouts. The project has two +// environments, "main" (the default) and "staging"; the CLI is expected to pick +// the environment matching the checked-out branch, identifying the project from +// either the Git remote or .platform/local/project.yaml. +func TestEnvironmentSelectionByLayout(t *testing.T) { + authServer := mockapi.NewAuthServer(t) + defer authServer.Close() + + apiHandler := mockapi.NewHandler(t) + apiHandler.SetProjects([]*mockapi.Project{{ + ID: layoutProjectID, + Title: "Layout Test", + DefaultBranch: "main", + Repository: mockapi.ProjectRepository{URL: layoutGitURL}, + Links: mockapi.MakeHALLinks( + "self=/projects/"+layoutProjectID, + "environments=/projects/"+layoutProjectID+"/environments", + ), + }}) + apiHandler.SetEnvironments([]*mockapi.Environment{ + makeEnv(layoutProjectID, "main", "production", "active", nil), + makeEnv(layoutProjectID, "staging", "development", "active", "main"), + }) + + apiServer := httptest.NewServer(apiHandler) + defer apiServer.Close() + + cases := []struct { + name string + layout repoLayout + wantEnv string // the environment "env:info" is expected to select + wantErr string // a substring expected on stderr when no environment is selected + }{ + { + name: "git repo on the default branch, project from Git remote", + layout: repoLayout{git: true, branch: "main", remoteName: "platform-test"}, + wantEnv: "main", + }, + { + name: "git repo on a branch matching an environment", + layout: repoLayout{git: true, branch: "staging", remoteName: "platform-test"}, + wantEnv: "staging", + }, + { + name: "git repo identified by project.yaml without a remote", + layout: repoLayout{git: true, branch: "staging", projectYAML: true}, + wantEnv: "staging", + }, + { + name: "git worktree on a branch, nested in a parent on the default branch", + layout: repoLayout{git: true, worktree: true, branch: "staging", remoteName: "platform-test"}, + wantEnv: "staging", + }, + { + name: "git repo on a branch with no matching environment", + layout: repoLayout{git: true, branch: "feature-x", remoteName: "platform-test"}, + wantErr: "Could not determine the current environment", + }, + { + name: "git repo with a remote that is neither the configured name nor origin", + layout: repoLayout{git: true, branch: "main", remoteName: "github"}, + wantErr: "Could not determine the current project", + }, + { + name: "no git repository, project.yaml present but not anchored to a repo", + layout: repoLayout{git: false, projectYAML: true}, + wantErr: "Could not determine the current project", + }, + { + name: "no git repository and no project config", + layout: repoLayout{git: false}, + wantErr: "Could not determine the current project", + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + f := newCommandFactory(t, apiServer.URL, authServer.URL) + f.dir = c.layout.build(t) + + stdOut, stdErr, err := f.RunCombinedOutput("env:info", "name") + + if c.wantErr != "" { + require.Error(t, err, "expected a failure; stdout: %s stderr: %s", stdOut, stdErr) + assert.Contains(t, stdErr, c.wantErr) + return + } + + require.NoError(t, err, "stderr: %s", stdErr) + assertTrimmed(t, c.wantEnv, stdOut) + }) + } +} diff --git a/integration-tests/set_remote_test.go b/integration-tests/set_remote_test.go index efeef393..92d3fe88 100644 --- a/integration-tests/set_remote_test.go +++ b/integration-tests/set_remote_test.go @@ -91,6 +91,37 @@ func TestProjectSetRemote(t *testing.T) { assert.Equal(t, gitURL+"\n", gitConfig(t, repo, "remote.platform-test.url")) }) + t.Run("with project ID in a git worktree", func(t *testing.T) { + // In a worktree, .git is a file (a gitlink), not a directory. With the + // worktree nested inside a parent checkout, set-remote must act on the + // worktree rather than climbing up to the parent repository. + parent := initGitRepo(t) + // A commit is required before a worktree can be added. + require.NoError(t, os.WriteFile(filepath.Join(parent, "README.md"), []byte("test\n"), 0o644)) + runGit(t, parent, "add", "-A") + runGit(t, parent, "commit", "--quiet", "-m", "Initial commit") + + worktree := filepath.Join(parent, "nested", "worktree") + runGit(t, parent, "worktree", "add", "-b", "feature", worktree) + + f := newCommandFactory(t, apiServer.URL, authServer.URL) + f.dir = worktree + + _, stdErr, err := f.RunCombinedOutput("set-remote", projectID) + require.NoError(t, err, "stderr: %s", stdErr) + assert.Contains(t, stdErr, "Setting the remote project for this repository to:") + + // The local config must be written in the worktree, not the parent. + body, readErr := os.ReadFile(filepath.Join(worktree, ".platform", "local", "project.yaml")) + require.NoError(t, readErr) + assert.Contains(t, string(body), "id: "+projectID) + + _, statErr := os.Stat(filepath.Join(parent, ".platform")) + assert.True(t, os.IsNotExist(statErr), "config must not be written to the parent repository") + + assert.Equal(t, gitURL+"\n", gitConfig(t, worktree, "remote.platform-test.url")) + }) + t.Run("with unknown project ID", func(t *testing.T) { repo := initGitRepo(t) f := newCommandFactory(t, apiServer.URL, authServer.URL) diff --git a/legacy/phpstan-baseline.neon b/legacy/phpstan-baseline.neon index 76668f57..e249aa61 100644 --- a/legacy/phpstan-baseline.neon +++ b/legacy/phpstan-baseline.neon @@ -805,11 +805,6 @@ parameters: count: 1 path: tests/Command/User/UserAddCommandTest.php - - - message: "#^Parameter \\#1 \\$parentDir of method Platformsh\\\\Cli\\\\Tests\\\\Local\\\\BuildFlavor\\\\BuildFlavorTestBase\\:\\:createTempDir\\(\\) expects string, string\\|null given\\.$#" - count: 1 - path: tests/Local/BuildFlavor/BuildFlavorTestBase.php - - message: "#^Cannot call method getTreeId\\(\\) on Platformsh\\\\Cli\\\\Local\\\\LocalBuild\\|null\\.$#" count: 2 @@ -820,21 +815,6 @@ parameters: count: 1 path: tests/Local/LocalBuildTest.php - - - message: "#^Parameter \\#1 \\$parentDir of method Platformsh\\\\Cli\\\\Tests\\\\Service\\\\DrushServiceTest\\:\\:createTempDir\\(\\) expects string, string\\|null given\\.$#" - count: 1 - path: tests/Service/DrushServiceTest.php - - - - message: "#^Parameter \\#1 \\$parentDir of method Platformsh\\\\Cli\\\\Tests\\\\Service\\\\FilesystemServiceTest\\:\\:createTempDir\\(\\) expects string, string\\|null given\\.$#" - count: 1 - path: tests/Service/FilesystemServiceTest.php - - - - message: "#^Parameter \\#1 \\$parentDir of method Platformsh\\\\Cli\\\\Tests\\\\Service\\\\GitServiceTest\\:\\:createTempDir\\(\\) expects string, string\\|null given\\.$#" - count: 1 - path: tests/Service/GitServiceTest.php - - message: "#^Parameter \\#1 \\$objectOrMethod of class ReflectionMethod constructor expects object\\|string, Platformsh\\\\Cli\\\\Service\\\\Ssh\\|null given\\.$#" count: 2 diff --git a/legacy/src/Local/LocalProject.php b/legacy/src/Local/LocalProject.php index b9462640..bac75ea3 100644 --- a/legacy/src/Local/LocalProject.php +++ b/legacy/src/Local/LocalProject.php @@ -270,7 +270,9 @@ public function getProjectConfig(?string $projectRoot = null): ?array $yaml = new Parser(); $projectConfig = $yaml->parse((string) file_get_contents($projectRoot . '/' . $configFilename)); self::$projectConfigs[$projectRoot] = $projectConfig; - } elseif ($projectRoot && is_dir($projectRoot . '/.git')) { + } elseif ($projectRoot && file_exists($projectRoot . '/.git')) { + // Use file_exists rather than is_dir: in a Git worktree (or + // submodule) .git is a file (a gitlink), not a directory. $gitUrl = $this->getGitRemoteUrl($projectRoot); if ($gitUrl && ($projectConfig = $this->parseGitUrl($gitUrl))) { $this->writeCurrentProjectConfig($projectConfig, $projectRoot); @@ -359,7 +361,7 @@ public function ensureLocalDir(string $projectRoot): void public function writeGitExclude(string $dir): void { $filesToExclude = ['/' . $this->config->getStr('local.local_dir'), '/' . $this->config->getStr('local.web_root')]; - $excludeFilename = $dir . '/.git/info/exclude'; + $excludeFilename = $this->getGitExcludePath($dir); $existing = ''; // Skip writing anything if the contents already include the @@ -390,4 +392,26 @@ public function writeGitExclude(string $dir): void } $this->fs->dumpFile($excludeFilename, $content); } + + /** + * Finds the path to a repository's info/exclude file. + * + * In a Git worktree .git is a file, so the exclude file is not at + * .git/info/exclude; git resolves it to the shared common directory. + * + * @param string $dir The repository (or worktree) directory. + */ + private function getGitExcludePath(string $dir): string + { + $path = $this->git->execute(['rev-parse', '--git-path', 'info/exclude'], $dir); + if (!is_string($path) || $path === '') { + return $dir . '/.git/info/exclude'; + } + // The path may be returned relative to the repository directory. + if (!preg_match('#^(/|[a-zA-Z]:[\\\\/])#', $path)) { + $path = $dir . '/' . $path; + } + + return $path; + } } diff --git a/legacy/src/Service/Git.php b/legacy/src/Service/Git.php index 092a9f50..f7833985 100644 --- a/legacy/src/Service/Git.php +++ b/legacy/src/Service/Git.php @@ -425,7 +425,9 @@ public function getRoot(?string $dir = null, bool $mustRun = false): string|fals $current = $dir; while (true) { - if (is_dir($current . '/.git')) { + // file_exists rather than is_dir: in a Git worktree (or submodule) + // .git is a file (a gitlink), not a directory. + if (file_exists($current . '/.git')) { return realpath($current) ?: $current; } diff --git a/legacy/tests/HasTempDirTrait.php b/legacy/tests/HasTempDirTrait.php index 7e736d38..36eeaad1 100644 --- a/legacy/tests/HasTempDirTrait.php +++ b/legacy/tests/HasTempDirTrait.php @@ -40,6 +40,7 @@ protected function createTempDir(string $parentDir, string $prefix = ''): string protected function createTempSubDir(string $prefix = ''): string { $this->tempDirSetUp(); + assert($this->tempDir !== null); return $this->createTempDir($this->tempDir, $prefix); }