Skip to content
Open
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
190 changes: 190 additions & 0 deletions integration-tests/environment_select_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
}
31 changes: 31 additions & 0 deletions integration-tests/set_remote_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
20 changes: 0 additions & 20 deletions legacy/phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
28 changes: 26 additions & 2 deletions legacy/src/Local/LocalProject.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
}
4 changes: 3 additions & 1 deletion legacy/src/Service/Git.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
1 change: 1 addition & 0 deletions legacy/tests/HasTempDirTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down