From 930085021a2081ee7ad3582217e11e085aea98f3 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Tue, 28 Apr 2026 10:20:18 +0100 Subject: [PATCH 1/3] fix(legacy): allow set-remote with project ID outside a project root The unified Selector::getSelection() reads project IDs from the --project option only, so the ProjectSetRemoteCommand argument was discarded and the selector then tried to detect the current project from disk. In a fresh checkout (e.g. CI runners) that produced a RootNotFoundException even when the project ID was supplied as an argument. Bypass getSelection() when an argument is given and load the project directly via the API, matching the pre-5.x behavior. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Project/ProjectSetRemoteCommand.php | 31 ++++++++++--------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/legacy/src/Command/Project/ProjectSetRemoteCommand.php b/legacy/src/Command/Project/ProjectSetRemoteCommand.php index 39abd974f..78e8a5b4d 100644 --- a/legacy/src/Command/Project/ProjectSetRemoteCommand.php +++ b/legacy/src/Command/Project/ProjectSetRemoteCommand.php @@ -38,17 +38,14 @@ protected function configure(): void protected function execute(InputInterface $input, OutputInterface $output): int { - $projectId = $input->getArgument('project'); - $unset = false; - if ($projectId === '-') { - $unset = true; - $projectId = null; - } - - if ($projectId) { - $identifier = $this->identifier; - $result = $identifier->identify($projectId); + $projectArg = $input->getArgument('project'); + $unset = $projectArg === '-'; + $projectId = null; + $projectHost = null; + if (!$unset && is_string($projectArg) && $projectArg !== '') { + $result = $this->identifier->identify($projectArg); $projectId = $result['projectId']; + $projectHost = $result['host']; } $cwd = getcwd(); if (!$cwd) { @@ -123,13 +120,17 @@ protected function execute(InputInterface $input, OutputInterface $output): int $selectorConfig = null; } - $asking = $projectId === null; - $selection = $this->selector->getSelection($input, $selectorConfig); - if ($asking) { + if ($projectId !== null) { + $project = $this->api->getProject($projectId, $projectHost); + if (!$project) { + $this->stdErr->writeln(sprintf('Project not found: %s', $projectId)); + return 1; + } + } else { + $selection = $this->selector->getSelection($input, $selectorConfig); $this->stdErr->writeln(''); + $project = $selection->getProject(); } - - $project = $selection->getProject(); if ($currentProject && $currentProject->id === $project->id) { $this->stdErr->writeln(sprintf( 'The remote project for this repository is already set as: %s', From 2c4fa81083192d8a377da7cb344141888f4eca21 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Tue, 28 Apr 2026 10:28:07 +0100 Subject: [PATCH 2/3] refactor(legacy): drop unused host argument in set-remote The Api service ignores the project host when an api.base_url is configured (always the case for Upsun), so passing it through from Identifier::identify() is dead weight. Co-Authored-By: Claude Opus 4.7 (1M context) --- legacy/src/Command/Project/ProjectSetRemoteCommand.php | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/legacy/src/Command/Project/ProjectSetRemoteCommand.php b/legacy/src/Command/Project/ProjectSetRemoteCommand.php index 78e8a5b4d..d91cff754 100644 --- a/legacy/src/Command/Project/ProjectSetRemoteCommand.php +++ b/legacy/src/Command/Project/ProjectSetRemoteCommand.php @@ -41,11 +41,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int $projectArg = $input->getArgument('project'); $unset = $projectArg === '-'; $projectId = null; - $projectHost = null; if (!$unset && is_string($projectArg) && $projectArg !== '') { - $result = $this->identifier->identify($projectArg); - $projectId = $result['projectId']; - $projectHost = $result['host']; + $projectId = $this->identifier->identify($projectArg)['projectId']; } $cwd = getcwd(); if (!$cwd) { @@ -121,7 +118,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int } if ($projectId !== null) { - $project = $this->api->getProject($projectId, $projectHost); + $project = $this->api->getProject($projectId); if (!$project) { $this->stdErr->writeln(sprintf('Project not found: %s', $projectId)); return 1; From af67dff6f6ef0644999ff2dea35efa3f86361e32 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Tue, 28 Apr 2026 10:28:13 +0100 Subject: [PATCH 3/3] test(integration): cover project:set-remote behavior Adds an integration test exercising the set-remote command across the main code paths: the regression case with a project ID in a fresh git checkout (which previously failed with RootNotFoundException), an unknown project ID, running outside a git repository, an unset call with nothing mapped, and the non-interactive case with no argument. Adds a 'dir' field on cmdFactory so commands can be run from a specific working directory rather than the default temp directory. Co-Authored-By: Claude Opus 4.7 (1M context) --- integration-tests/set_remote_test.go | 141 +++++++++++++++++++++++++++ integration-tests/tests.go | 6 +- 2 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 integration-tests/set_remote_test.go diff --git a/integration-tests/set_remote_test.go b/integration-tests/set_remote_test.go new file mode 100644 index 000000000..efeef393c --- /dev/null +++ b/integration-tests/set_remote_test.go @@ -0,0 +1,141 @@ +package tests + +import ( + "net/http/httptest" + "net/url" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/upsun/cli/pkg/mockapi" +) + +// initGitRepo creates an empty Git repository in a temporary directory and +// returns its path. +func initGitRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + for _, args := range [][]string{ + {"init", "--quiet", "--initial-branch=main"}, + {"config", "user.email", "test@example.com"}, + {"config", "user.name", "Test"}, + {"config", "commit.gpgsign", "false"}, + } { + cmd := exec.Command("git", args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + require.NoError(t, err, "git %v failed: %s", args, out) + } + return dir +} + +func gitConfig(t *testing.T, dir, key string) string { + t.Helper() + out, err := exec.Command("git", "-C", dir, "config", "--get", key).Output() + if err != nil { + var ee *exec.ExitError + if ok := assert.ErrorAs(t, err, &ee); ok && ee.ExitCode() == 1 { + return "" + } + require.NoError(t, err) + } + return string(out) +} + +func TestProjectSetRemote(t *testing.T) { + authServer := mockapi.NewAuthServer(t) + defer authServer.Close() + + apiHandler := mockapi.NewHandler(t) + + projectID := mockapi.ProjectID() + gitURL := "test-user@git.cli-tests.example.com:" + projectID + ".git" + apiHandler.SetProjects([]*mockapi.Project{{ + ID: projectID, + Title: "Set Remote Test", + Region: "test-region", + DefaultBranch: "main", + Repository: mockapi.ProjectRepository{URL: gitURL}, + Links: mockapi.MakeHALLinks("self=/projects/" + url.PathEscape(projectID)), + }}) + + apiServer := httptest.NewServer(apiHandler) + defer apiServer.Close() + + t.Run("with project ID in fresh git repo", func(t *testing.T) { + // Regression test: the customer-reported bug was that running + // "set-remote PROJECT_ID" in a fresh Git checkout (no .platform/local + // config) failed with RootNotFoundException, because the unified + // Selector ignored the positional argument and tried to detect a + // current project from disk. + repo := initGitRepo(t) + f := newCommandFactory(t, apiServer.URL, authServer.URL) + f.dir = repo + + _, 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:") + assert.Contains(t, stdErr, projectID) + assert.NotContains(t, stdErr, "RootNotFoundException") + assert.NotContains(t, stdErr, "Could not determine the current project") + + configFile := filepath.Join(repo, ".platform", "local", "project.yaml") + body, readErr := os.ReadFile(configFile) + require.NoError(t, readErr) + assert.Contains(t, string(body), "id: "+projectID) + + assert.Equal(t, gitURL+"\n", gitConfig(t, repo, "remote.platform-test.url")) + }) + + t.Run("with unknown project ID", func(t *testing.T) { + repo := initGitRepo(t) + f := newCommandFactory(t, apiServer.URL, authServer.URL) + f.dir = repo + + _, stdErr, err := f.RunCombinedOutput("set-remote", "nonexistent") + ee := &exec.ExitError{} + require.ErrorAs(t, err, &ee) + assert.Equal(t, 1, ee.ExitCode()) + assert.Contains(t, stdErr, "Project not found") + + _, statErr := os.Stat(filepath.Join(repo, ".platform")) + assert.True(t, os.IsNotExist(statErr), "no project config should be written on failure") + }) + + t.Run("outside a git repository", func(t *testing.T) { + f := newCommandFactory(t, apiServer.URL, authServer.URL) + f.dir = t.TempDir() + + _, stdErr, err := f.RunCombinedOutput("set-remote", projectID) + ee := &exec.ExitError{} + require.ErrorAs(t, err, &ee) + assert.Equal(t, 1, ee.ExitCode()) + assert.Contains(t, stdErr, "No Git repository found") + }) + + t.Run("unset when nothing is mapped", func(t *testing.T) { + repo := initGitRepo(t) + f := newCommandFactory(t, apiServer.URL, authServer.URL) + f.dir = repo + + _, stdErr, err := f.RunCombinedOutput("set-remote", "-") + require.NoError(t, err, "stderr: %s", stdErr) + assert.Contains(t, stdErr, "This repository is not mapped to a remote project.") + }) + + t.Run("without project ID in non-interactive mode", func(t *testing.T) { + repo := initGitRepo(t) + f := newCommandFactory(t, apiServer.URL, authServer.URL) + f.dir = repo + + _, stdErr, err := f.RunCombinedOutput("set-remote") + ee := &exec.ExitError{} + require.ErrorAs(t, err, &ee) + assert.NotEqual(t, 0, ee.ExitCode()) + assert.Contains(t, stdErr, "Could not determine the current project") + }) +} diff --git a/integration-tests/tests.go b/integration-tests/tests.go index f63335bf1..f049dac03 100644 --- a/integration-tests/tests.go +++ b/integration-tests/tests.go @@ -61,6 +61,7 @@ type cmdFactory struct { apiURL string authURL string extraEnv []string + dir string } func newCommandFactory(t *testing.T, apiURL, authURL string) *cmdFactory { @@ -94,7 +95,10 @@ func (f *cmdFactory) RunCombinedOutput(args ...string) (stdOut, stdErr string, e func (f *cmdFactory) buildCommand(args ...string) *exec.Cmd { cmd := exec.Command(getCommandName(f.t), args...) //nolint:gosec cmd.Env = testEnv() - cmd.Dir = os.TempDir() + cmd.Dir = f.dir + if cmd.Dir == "" { + cmd.Dir = os.TempDir() + } if testing.Verbose() { cmd.Stderr = os.Stderr }