From 9fb9638433470b80b6298ed8c58ad1a14fb40f95 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 19 Jun 2026 13:55:07 +0100 Subject: [PATCH 1/4] fix(legacy): detect Git worktree as its own project root Project and Git root detection gated on is_dir('.git'), which is false in a Git worktree where .git is a file (a gitlink). When a worktree was nested inside another checkout of the same repository, detection climbed up to the parent repo and read its branch, so the CLI auto-selected an environment matching the parent's branch (e.g. main) instead of the worktree's branch. - LocalProject::getProjectConfig() and Git::getRoot() now use file_exists, which is true for both a .git directory and a .git gitlink file. - writeGitExclude() resolved the exclude path as .git/info/exclude, which does not exist in a worktree; it now asks git for the path (rev-parse --git-path info/exclude), which points at the shared common directory. Add regression tests covering getRoot() and getProjectRoot() in a worktree nested inside a parent repository. Co-Authored-By: Claude Opus 4.8 (1M context) --- legacy/src/Local/LocalProject.php | 28 +++++++++- legacy/src/Service/Git.php | 4 +- legacy/tests/Local/LocalProjectTest.php | 69 +++++++++++++++++++++++++ legacy/tests/Service/GitServiceTest.php | 21 ++++++++ 4 files changed, 119 insertions(+), 3 deletions(-) create mode 100644 legacy/tests/Local/LocalProjectTest.php diff --git a/legacy/src/Local/LocalProject.php b/legacy/src/Local/LocalProject.php index b9462640c..bac75ea34 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 092a9f508..f7833985d 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/Local/LocalProjectTest.php b/legacy/tests/Local/LocalProjectTest.php new file mode 100644 index 000000000..7de999af6 --- /dev/null +++ b/legacy/tests/Local/LocalProjectTest.php @@ -0,0 +1,69 @@ +tempDirSetUp(); + } + + /** + * A Git worktree must be recognised as its own project root. + * + * Regression test: in a worktree, .git is a file (a gitlink), not a + * directory. When the worktree is nested inside another checkout of the + * same repository, project-root detection used to skip the worktree and + * climb up to the parent, picking up the parent's branch (e.g. "main"). + */ + public function testGetProjectRootInWorktree(): void + { + $git = new Git(); + + // A parent repository with an Upsun-style Git remote and no local + // project config file, so detection relies on the remote URL. + $parentDir = $this->tempDir . '/parent'; + if (!mkdir($parentDir, 0o755, true)) { + throw new \RuntimeException('Failed to create directory: ' . $parentDir); + } + $git->init($parentDir, 'main', true); + $git->execute(['config', 'user.email', 'test@example.com'], $parentDir, true); + $git->execute(['config', 'user.name', 'Test'], $parentDir, true); + $git->execute(['config', 'commit.gpgsign', 'false'], $parentDir, true); + $git->execute( + ['remote', 'add', 'platform', 'abcdefghijkl@git.eu-5.example.com:abcdefghijkl.git'], + $parentDir, + true, + ); + touch($parentDir . '/README.txt'); + $git->execute(['add', '-A'], $parentDir, true); + $git->execute(['commit', '-qm', 'Initial commit'], $parentDir, true); + + // A worktree nested inside the parent's working tree, on its own branch. + $worktreeDir = $parentDir . '/nested/worktree'; + $git->execute(['worktree', 'add', '-b', 'feature', $worktreeDir], $parentDir, true); + $this->assertFalse(is_dir($worktreeDir . '/.git'), 'In a worktree, .git is a file'); + + $config = (new Config())->withOverrides([ + 'detection.git_domain' => 'example.com', + 'detection.git_remote_name' => 'platform', + 'local.project_config' => '.platform/local/project.yaml', + ]); + $localProject = new LocalProject($config, $git); + + $this->assertEquals(realpath($worktreeDir), $localProject->getProjectRoot($worktreeDir)); + } +} diff --git a/legacy/tests/Service/GitServiceTest.php b/legacy/tests/Service/GitServiceTest.php index 13d182eac..a22d121fb 100644 --- a/legacy/tests/Service/GitServiceTest.php +++ b/legacy/tests/Service/GitServiceTest.php @@ -76,6 +76,27 @@ public function testGetRoot(): void $this->git->getRoot($this->tempDir, true); } + /** + * Test GitHelper::getRoot() inside a Git worktree. + * + * In a worktree, .git is a file (a gitlink), not a directory. + */ + public function testGetRootInWorktree(): void + { + $repositoryDir = $this->getRepositoryDir(); + + // Add the worktree inside the main repository's working tree, to + // mimic a worktree nested under another checkout. + $worktreeDir = $repositoryDir . '/nested/worktree'; + $this->git->execute(['worktree', 'add', '-b', 'feature', $worktreeDir], $repositoryDir, true); + + $this->assertFileExists($worktreeDir . '/.git'); + $this->assertFalse(is_dir($worktreeDir . '/.git'), 'In a worktree, .git is a file'); + + // getRoot() must resolve to the worktree itself, not the parent. + $this->assertEquals(realpath($worktreeDir), $this->git->getRoot($worktreeDir)); + } + /** * Get a Git repository directory. * From 179c360db5c38f9ce9578a9992302d2418eb73b6 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 19 Jun 2026 14:39:39 +0100 Subject: [PATCH 2/4] test: narrow tempDir type in HasTempDirTrait for PHPStan LocalProjectTest uses HasTempDirTrait, and PHPStan analyses the trait's methods in the context of each using class. createTempSubDir() passed the nullable $tempDir to createTempDir(), which expects a non-null string. Add an assert() after tempDirSetUp() to narrow the type, and drop the four now-obsolete baseline entries that suppressed this error per class. Co-Authored-By: Claude Opus 4.8 (1M context) --- legacy/phpstan-baseline.neon | 20 -------------------- legacy/tests/HasTempDirTrait.php | 1 + 2 files changed, 1 insertion(+), 20 deletions(-) diff --git a/legacy/phpstan-baseline.neon b/legacy/phpstan-baseline.neon index 76668f579..e249aa61e 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/tests/HasTempDirTrait.php b/legacy/tests/HasTempDirTrait.php index 7e736d38c..36eeaad17 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); } From 234720b719c644a48afd8ec8074344d164763631 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 19 Jun 2026 15:55:18 +0100 Subject: [PATCH 3/4] test: characterize environment selection with a Go integration test Replace the PHPUnit worktree regression tests with a table-driven Go integration test in integration-tests/. The PHPUnit tests were in the "slow" group, which the legacy test run excludes (phpunit --exclude-group slow), so they did not run routinely. A Go integration test also survives the eventual move of this logic from PHP to Go. TestEnvironmentSelectionByLayout runs "env:info" across a range of local checkout layouts (git/no-git, worktree or not, project identified by the Git remote or by .platform/local/project.yaml, branch with or without a matching environment) and asserts the selected environment, exercising the same worktree project-root detection as the removed tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- integration-tests/environment_select_test.go | 190 +++++++++++++++++++ legacy/tests/Local/LocalProjectTest.php | 69 ------- legacy/tests/Service/GitServiceTest.php | 21 -- 3 files changed, 190 insertions(+), 90 deletions(-) create mode 100644 integration-tests/environment_select_test.go delete mode 100644 legacy/tests/Local/LocalProjectTest.php diff --git a/integration-tests/environment_select_test.go b/integration-tests/environment_select_test.go new file mode 100644 index 000000000..e198640dd --- /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/legacy/tests/Local/LocalProjectTest.php b/legacy/tests/Local/LocalProjectTest.php deleted file mode 100644 index 7de999af6..000000000 --- a/legacy/tests/Local/LocalProjectTest.php +++ /dev/null @@ -1,69 +0,0 @@ -tempDirSetUp(); - } - - /** - * A Git worktree must be recognised as its own project root. - * - * Regression test: in a worktree, .git is a file (a gitlink), not a - * directory. When the worktree is nested inside another checkout of the - * same repository, project-root detection used to skip the worktree and - * climb up to the parent, picking up the parent's branch (e.g. "main"). - */ - public function testGetProjectRootInWorktree(): void - { - $git = new Git(); - - // A parent repository with an Upsun-style Git remote and no local - // project config file, so detection relies on the remote URL. - $parentDir = $this->tempDir . '/parent'; - if (!mkdir($parentDir, 0o755, true)) { - throw new \RuntimeException('Failed to create directory: ' . $parentDir); - } - $git->init($parentDir, 'main', true); - $git->execute(['config', 'user.email', 'test@example.com'], $parentDir, true); - $git->execute(['config', 'user.name', 'Test'], $parentDir, true); - $git->execute(['config', 'commit.gpgsign', 'false'], $parentDir, true); - $git->execute( - ['remote', 'add', 'platform', 'abcdefghijkl@git.eu-5.example.com:abcdefghijkl.git'], - $parentDir, - true, - ); - touch($parentDir . '/README.txt'); - $git->execute(['add', '-A'], $parentDir, true); - $git->execute(['commit', '-qm', 'Initial commit'], $parentDir, true); - - // A worktree nested inside the parent's working tree, on its own branch. - $worktreeDir = $parentDir . '/nested/worktree'; - $git->execute(['worktree', 'add', '-b', 'feature', $worktreeDir], $parentDir, true); - $this->assertFalse(is_dir($worktreeDir . '/.git'), 'In a worktree, .git is a file'); - - $config = (new Config())->withOverrides([ - 'detection.git_domain' => 'example.com', - 'detection.git_remote_name' => 'platform', - 'local.project_config' => '.platform/local/project.yaml', - ]); - $localProject = new LocalProject($config, $git); - - $this->assertEquals(realpath($worktreeDir), $localProject->getProjectRoot($worktreeDir)); - } -} diff --git a/legacy/tests/Service/GitServiceTest.php b/legacy/tests/Service/GitServiceTest.php index a22d121fb..13d182eac 100644 --- a/legacy/tests/Service/GitServiceTest.php +++ b/legacy/tests/Service/GitServiceTest.php @@ -76,27 +76,6 @@ public function testGetRoot(): void $this->git->getRoot($this->tempDir, true); } - /** - * Test GitHelper::getRoot() inside a Git worktree. - * - * In a worktree, .git is a file (a gitlink), not a directory. - */ - public function testGetRootInWorktree(): void - { - $repositoryDir = $this->getRepositoryDir(); - - // Add the worktree inside the main repository's working tree, to - // mimic a worktree nested under another checkout. - $worktreeDir = $repositoryDir . '/nested/worktree'; - $this->git->execute(['worktree', 'add', '-b', 'feature', $worktreeDir], $repositoryDir, true); - - $this->assertFileExists($worktreeDir . '/.git'); - $this->assertFalse(is_dir($worktreeDir . '/.git'), 'In a worktree, .git is a file'); - - // getRoot() must resolve to the worktree itself, not the parent. - $this->assertEquals(realpath($worktreeDir), $this->git->getRoot($worktreeDir)); - } - /** * Get a Git repository directory. * From 46c49fd8f4067ef0a5e65acfb2cedcfde1a08092 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 19 Jun 2026 16:00:04 +0100 Subject: [PATCH 4/4] test(set-remote): cover Git::getRoot() in a worktree set-remote resolves the repository root via Git::getRoot(). Add a subtest that runs it in a worktree nested inside a parent checkout and asserts the local project config is written in the worktree, not the parent. Co-Authored-By: Claude Opus 4.8 (1M context) --- integration-tests/set_remote_test.go | 31 ++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/integration-tests/set_remote_test.go b/integration-tests/set_remote_test.go index efeef393c..92d3fe88d 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)