From 0ec3882cb8c8f8bb96f3f5961fe9cb75b62ce493 Mon Sep 17 00:00:00 2001 From: slashdevcorpse <73707049+slashdevcorpse@users.noreply.github.com> Date: Wed, 17 Jun 2026 19:08:14 -0400 Subject: [PATCH] Add project Windows runtime selection (#5519) * Add project Windows runtime selection * Fix project Windows runtime selection Co-authored-by: Orca * fix: preserve WSL shell variables --------- Co-authored-by: Jinwoo-H Co-authored-by: Orca Co-authored-by: Neil --- .gitattributes | 8 + config/scripts/ensure-native-runtime.mjs | 2 +- config/scripts/orca-dev-bin.test.mjs | 2 +- config/scripts/rebuild-native-deps.test.mjs | 17 +- src/cli/runtime/environments.test.ts | 18 +- src/main/agent-hooks/installer-utils.ts | 12 +- src/main/agent-trust-presets.test.ts | 4 +- src/main/agent-trust-presets.ts | 6 +- src/main/antigravity/hook-service.test.ts | 43 +- .../attribution/terminal-attribution.test.ts | 212 +-- src/main/automations/precheck-runner.ts | 2 +- .../azure-devops/azure-devops-api-request.ts | 105 ++ src/main/azure-devops/client.ts | 155 +- src/main/azure-devops/repository-ref.test.ts | 47 +- src/main/azure-devops/repository-ref.ts | 18 +- src/main/bitbucket/client.ts | 27 +- src/main/bitbucket/repository-ref.test.ts | 34 + src/main/bitbucket/repository-ref.ts | 18 +- .../browser-cookie-import.comet.test.ts | 21 +- ...owser-session-registry.persistence.test.ts | 40 +- .../runtime-auth-service.test.ts | 79 +- .../claude-accounts/runtime-auth-service.ts | 33 +- src/main/claude/hook-service.test.ts | 18 +- src/main/cli/packaged-cli-assets.test.ts | 8 +- .../runtime-home-service.test.ts | 1 + src/main/codex-accounts/service.test.ts | 1 + src/main/codex/hook-service.test.ts | 2 +- src/main/command-code/hook-service.test.ts | 6 +- src/main/cursor/hook-service.test.ts | 17 +- src/main/daemon/client.test.ts | 3 +- src/main/daemon/daemon-health.test.ts | 4 +- src/main/daemon/daemon-init.test.ts | 81 +- src/main/daemon/daemon-main.test.ts | 3 +- src/main/daemon/daemon-pty-adapter.test.ts | 3 +- src/main/daemon/daemon-pty-provider.test.ts | 3 +- src/main/daemon/daemon-server.test.ts | 3 +- src/main/daemon/daemon-spawner.test.ts | 6 +- src/main/daemon/history-manager.test.ts | 103 +- src/main/daemon/osc7-file-uri.ts | 14 +- src/main/daemon/production-launcher.test.ts | 3 +- .../slow-daemon-session-verification.test.ts | 13 +- src/main/daemon/terminal-host.test.ts | 4 +- src/main/ghostty/discovery.ts | 11 +- src/main/git/check-ignored-paths.ts | 12 +- src/main/git/checkout.ts | 15 +- src/main/git/fork-sync.ts | 7 +- src/main/git/git-runtime-options.ts | 10 + src/main/git/history.ts | 6 +- src/main/git/huge-folder-ignore.ts | 8 +- src/main/git/push-target-validation.ts | 12 +- src/main/git/remote.test.ts | 20 + src/main/git/remote.ts | 149 +- src/main/git/remove-worktree.test.ts | 43 +- src/main/git/repo.ts | 69 +- src/main/git/runner-command-exec.test.ts | 67 +- src/main/git/runner-wsl-gh-fallback.test.ts | 6 +- src/main/git/runner.ts | 111 +- src/main/git/status-cquoted-paths.test.ts | 4 +- src/main/git/status-discard-symlink.test.ts | 43 +- src/main/git/status-pathspec-literals.test.ts | 44 +- src/main/git/status.ts | 268 ++- src/main/git/upstream.test.ts | 34 + src/main/git/upstream.ts | 29 +- src/main/git/worktree-base-ref-probe.ts | 10 +- src/main/git/worktree.ts | 183 ++- src/main/gitea/client.ts | 27 +- src/main/gitea/repository-ref.test.ts | 34 + src/main/gitea/repository-ref.ts | 18 +- src/main/github/client-create-pr.test.ts | 36 + src/main/github/client-file-viewed.test.ts | 31 +- src/main/github/client-issue-source.test.ts | 11 +- src/main/github/client-pr-checks.test.ts | 59 +- .../github/client-pr-local-runtime.test.ts | 241 +++ src/main/github/client-work-items.test.ts | 58 +- src/main/github/client.test.ts | 79 +- src/main/github/client.ts | 353 ++-- src/main/github/conflict-summary.ts | 57 +- src/main/github/gh-error-classification.ts | 55 + src/main/github/gh-utils.test.ts | 25 + src/main/github/gh-utils.ts | 393 +---- src/main/github/github-repository-identity.ts | 294 ++++ src/main/github/issues.test.ts | 75 +- src/main/github/issues.ts | 72 +- src/main/github/pr-head-tracking-ref.ts | 13 +- .../github/pr-refresh-coordinator.test.ts | 47 + src/main/github/pr-refresh-coordinator.ts | 22 +- src/main/github/pr-start-point.ts | 18 +- src/main/github/work-item-details.test.ts | 90 +- src/main/github/work-item-details.ts | 166 +- src/main/gitlab/client-mr.test.ts | 173 ++ src/main/gitlab/client.test.ts | 65 +- src/main/gitlab/client.ts | 200 ++- .../gitlab/gitlab-project-ref-resolution.ts | 282 ++++ src/main/gitlab/gl-utils.test.ts | 32 + src/main/gitlab/gl-utils.ts | 365 +--- src/main/gitlab/glab-error-classification.ts | 52 + src/main/gitlab/issues.test.ts | 70 + src/main/gitlab/issues.ts | 82 +- .../gitlab/merge-request-creation-lookup.ts | 87 + .../gitlab/merge-request-creation.test.ts | 33 + src/main/gitlab/merge-request-creation.ts | 108 +- src/main/gitlab/work-item-details.test.ts | 74 +- src/main/gitlab/work-item-details.ts | 75 +- src/main/grok/hook-service.test.ts | 15 +- src/main/hooks.test.ts | 180 +- src/main/hooks.ts | 95 +- src/main/index.ts | 4 +- src/main/ipc/filesystem-auth.test.ts | 2 +- ...ystem-list-files-git-fallback-real.test.ts | 10 +- src/main/ipc/filesystem-list-files.test.ts | 69 +- src/main/ipc/filesystem-list-files.ts | 48 +- src/main/ipc/filesystem-search-git.ts | 4 +- .../ipc/filesystem-search-rg-timeout.test.ts | 104 +- .../filesystem-watcher-large-batch.test.ts | 18 +- src/main/ipc/filesystem.test.ts | 202 ++- src/main/ipc/filesystem.ts | 340 +++- src/main/ipc/github-work-item-args.test.ts | 8 +- src/main/ipc/github-work-item-args.ts | 12 +- src/main/ipc/github.test.ts | 601 ++++++- src/main/ipc/github.ts | 140 +- src/main/ipc/gitlab.test.ts | 495 +++++- src/main/ipc/gitlab.ts | 123 +- src/main/ipc/hosted-review.test.ts | 135 +- src/main/ipc/hosted-review.ts | 43 +- .../ipc/local-worktree-runtime-options.ts | 61 + src/main/ipc/notebook.test.ts | 6 +- src/main/ipc/preflight-runtime-target.ts | 32 + src/main/ipc/preflight.test.ts | 80 + src/main/ipc/preflight.ts | 28 +- src/main/ipc/pty.test.ts | 420 ++++- src/main/ipc/pty.ts | 61 +- src/main/ipc/repos-create.test.ts | 53 +- src/main/ipc/repos.ts | 25 + src/main/ipc/rg-availability.ts | 8 +- src/main/ipc/skills.test.ts | 131 ++ src/main/ipc/skills.ts | 32 +- src/main/ipc/worktree-logic.test.ts | 24 +- src/main/ipc/worktree-logic.ts | 15 + src/main/ipc/worktree-remote.ts | 185 ++- src/main/ipc/worktrees-windows.test.ts | 8 + src/main/ipc/worktrees.test.ts | 348 +++- src/main/ipc/worktrees.ts | 160 +- src/main/local-project-runtime-resolution.ts | 64 + src/main/local-worktree-filesystem.test.ts | 96 ++ src/main/local-worktree-filesystem.ts | 121 ++ src/main/memory/collector.test.ts | 22 +- src/main/persistence.test.ts | 56 + src/main/persistence.ts | 50 +- .../local-workspace-port-scanner.test.ts | 7 +- .../ports/local-workspace-port-scanner.ts | 5 + src/main/project-runtime-git-options.test.ts | 151 ++ src/main/project-runtime-git-options.ts | 43 + src/main/providers/local-pty-provider.test.ts | 2 + src/main/providers/local-pty-provider.ts | 9 +- src/main/pty/shell-startup-env.ts | 10 +- .../claude-rate-limit-target.test.ts | 33 +- .../rate-limits/claude-rate-limit-target.ts | 25 +- .../codex-rate-limit-target.test.ts | 33 +- .../rate-limits/codex-rate-limit-target.ts | 25 +- .../project-runtime-rate-limit-target.ts | 29 + src/main/repo-worktrees.ts | 13 +- .../claude-agent-teams-shim-env.test.ts | 21 +- .../runtime/orca-runtime-files-watch.test.ts | 8 +- src/main/runtime/orca-runtime-files.test.ts | 54 + src/main/runtime/orca-runtime-files.ts | 14 +- src/main/runtime/orca-runtime-git.test.ts | 64 +- src/main/runtime/orca-runtime-git.ts | 185 ++- src/main/runtime/orca-runtime.test.ts | 1461 ++++++++++++++++- src/main/runtime/orca-runtime.ts | 813 +++++++-- src/main/runtime/orchestration/db.test.ts | 6 +- .../runtime/orchestration/preamble.test.ts | 40 +- ...ime-request-connection.integration.test.ts | 881 +++++----- .../methods/project-runtime-rpc-methods.ts | 20 + src/main/runtime/rpc/methods/repo.test.ts | 34 + src/main/runtime/runtime-rpc.test.ts | 6 +- src/main/runtime/runtime-socket-sweep.test.ts | 12 +- src/main/runtime/tls-certificate.ts | 67 +- src/main/source-control/forge-provider.ts | 196 +-- .../source-control/forge-review-mappers.ts | 74 + .../hosted-review-creation.test.ts | 55 + .../source-control/hosted-review-creation.ts | 111 +- .../hosted-review-git-options.ts | 20 + src/main/source-control/hosted-review.test.ts | 40 + src/main/source-control/hosted-review.ts | 29 +- .../ssh/ssh-config-loader-regression.test.ts | 32 +- src/main/ssh/ssh-system-fallback.test.ts | 21 +- .../startup/run-electron-vite-dev-web.test.ts | 6 +- src/main/terminal-history.test.ts | 32 +- src/main/terminal-history.ts | 8 +- .../commit-message-agent-environment.test.ts | 31 + .../commit-message-agent-environment.ts | 32 +- .../commit-message-text-generation.test.ts | 159 +- .../commit-message-text-generation.ts | 136 +- src/main/warp-themes/discovery.ts | 61 +- src/main/wsl.ts | 30 + src/preload/api-types.ts | 23 +- src/preload/index.ts | 13 +- src/relay/fs-handler.test.ts | 3 +- src/relay/git-handler-worktree-ops.ts | 187 +-- src/relay/git-handler-worktree-remove.ts | 233 +++ src/relay/git-handler.test.ts | 26 +- src/relay/relay-handshake-roundtrip.test.ts | 3 +- src/relay/relay-test-socket-path.ts | 17 + src/relay/subprocess.test.ts | 9 +- .../src/components/PullRequestPage.tsx | 13 +- ...ture-interaction-writer-boundaries.test.ts | 14 +- .../feature-wall/BrowserUseSkillSetupCard.tsx | 31 +- .../feature-wall/FeatureWallTourSurface.tsx | 4 + .../agent-capability-setup-status.ts | 5 + .../FloatingTerminalOrchestrationDialog.tsx | 32 +- .../components/right-sidebar/AiVaultPanel.tsx | 17 +- .../components/right-sidebar/ChecksPanel.tsx | 6 +- .../right-sidebar/SourceControl.tsx | 6 +- .../settings/AgentLocationSetting.tsx | 175 -- .../components/settings/AgentsPane.test.tsx | 15 +- .../src/components/settings/AgentsPane.tsx | 27 +- .../components/settings/BrowserUsePane.tsx | 69 +- .../settings/BrowserUseSkillStep.tsx | 6 + .../settings/CliAgentSkillSetup.tsx | 11 - .../components/settings/CliSection.test.tsx | 81 + .../src/components/settings/CliSection.tsx | 20 +- .../settings/CliSkillRuntimeSetup.test.tsx | 36 + .../settings/CliSkillRuntimeSetup.tsx | 88 +- .../components/settings/ComputerUsePane.tsx | 56 +- .../settings/ComputerUseSkillSetupPanel.tsx | 77 + ...faultWindowsProjectRuntimeSetting.test.tsx | 140 ++ .../DefaultWindowsProjectRuntimeSetting.tsx | 186 +++ .../components/settings/GeneralPane.test.ts | 20 + .../src/components/settings/GeneralPane.tsx | 32 +- .../components/settings/OrchestrationPane.tsx | 66 +- .../settings/OrchestrationSetupCard.tsx | 31 +- .../ProjectWindowsRuntimeSetting.test.tsx | 234 +++ .../settings/ProjectWindowsRuntimeSetting.tsx | 375 +++++ .../settings/RepositoryPane.test.ts | 136 +- .../components/settings/RepositoryPane.tsx | 153 +- .../RepositoryWindowsRuntimeSection.tsx | 77 + .../RepositoryWorktreeDefaultsSection.tsx | 100 ++ .../src/components/settings/Settings.tsx | 42 +- .../settings/TerminalAdvancedSection.tsx | 277 ++++ .../settings/TerminalInteractionSection.tsx | 178 ++ .../settings/TerminalMacKeyboardSection.tsx | 151 ++ .../settings/TerminalPane.pwsh.test.ts | 71 +- .../src/components/settings/TerminalPane.tsx | 1042 +----------- .../settings/TerminalRenderingSection.tsx | 97 ++ .../settings/TerminalSetupScriptSection.tsx | 120 ++ .../settings/TerminalWindowsShellSection.tsx | 130 ++ .../src/components/settings/agents-search.ts | 17 - .../general-project-runtime-search.ts | 30 + .../src/components/settings/general-search.ts | 30 +- .../settings/repository-identity-search.ts | 12 + .../repository-runtime-session-summary.ts | 59 + .../components/settings/repository-search.ts | 57 +- .../settings/terminal-search.test.ts | 18 + .../components/settings/terminal-search.ts | 16 +- .../settings/terminal-windows-search.ts | 64 - .../setup-guide/use-setup-guide-progress.ts | 5 + .../LinearAgentSkillSetupPrompt.test.tsx | 95 +- .../sidebar/LinearAgentSkillSetupPrompt.tsx | 27 +- .../folder-workspace-composer-submit.test.ts | 70 +- .../folder-workspace-composer-submit.ts | 14 +- .../sidebar/linear-agent-skill-runtime.ts | 83 +- .../src/components/status-bar/StatusBar.tsx | 5 +- .../status-bar-runtime-groups.test.ts | 33 +- .../components/tab-bar/BrowserTab.test.tsx | 2 +- .../src/components/tab-bar/TabBar.tsx | 116 +- .../TabBar.windows-shell-launch.test.ts | 112 +- .../task-page-source-switch-boundary.test.ts | 5 +- .../terminal-pane/pty-connection.test.ts | 115 ++ .../terminal-pane/pty-connection.ts | 22 + .../terminal-pane/pty-dispatcher.ts | 2 + .../components/terminal-pane/pty-transport.ts | 2 + .../terminal-agent-session-fork.test.ts | 64 + .../terminal-agent-session-fork.ts | 13 +- .../terminal-drop-handler.test.ts | 90 +- .../terminal-pane/terminal-drop-handler.ts | 32 +- .../src/hooks/useActiveProjectSkillRuntime.ts | 86 + ...poserState-host-context-boundaries.test.ts | 22 + src/renderer/src/hooks/useComposerState.ts | 30 +- .../src/hooks/useInstalledAgentSkills.test.ts | 77 + .../src/hooks/useInstalledAgentSkills.ts | 23 + .../useSettingsNavigationMetadata.test.ts | 55 + .../hooks/useSettingsNavigationMetadata.ts | 9 +- src/renderer/src/i18n/locales/en.json | 56 +- src/renderer/src/i18n/locales/es.json | 56 +- src/renderer/src/i18n/locales/ja.json | 56 +- src/renderer/src/i18n/locales/ko.json | 56 +- src/renderer/src/i18n/locales/zh.json | 56 +- src/renderer/src/lib/agent-launch-platform.ts | 10 +- .../src/lib/ai-vault-resume-command.test.ts | 96 ++ .../src/lib/ai-vault-resume-command.ts | 51 + .../src/lib/fix-checks-agent-launch.test.ts | 102 +- .../src/lib/fix-checks-agent-launch.ts | 7 +- .../launch-agent-background-session.test.ts | 93 +- .../lib/launch-agent-background-session.ts | 12 +- .../src/lib/launch-agent-in-new-tab.test.ts | 83 + .../src/lib/launch-agent-in-new-tab.ts | 26 +- .../src/lib/launch-work-item-direct-draft.ts | 13 + .../src/lib/launch-work-item-direct.test.ts | 66 + .../src/lib/launch-work-item-direct.ts | 41 +- .../src/lib/local-preflight-context.test.ts | 470 +++++- .../src/lib/local-preflight-context.ts | 295 +++- .../src/lib/project-skill-runtime.test.ts | 87 + src/renderer/src/lib/project-skill-runtime.ts | 88 + src/renderer/src/lib/renderer-app-platform.ts | 18 + .../lib/resume-sleeping-agent-session.test.ts | 46 + .../src/lib/resume-sleeping-agent-session.ts | 8 + .../source-control-launch-platform.test.ts | 22 + .../src/lib/source-control-launch-platform.ts | 8 + .../lib/windows-terminal-capabilities.test.ts | 3 + .../src/lib/windows-terminal-capabilities.ts | 4 + .../worktree-activation-created-agent.test.ts | 63 + src/renderer/src/lib/worktree-activation.ts | 19 +- .../src/store/slices/detected-agents.test.ts | 184 ++- .../src/store/slices/detected-agents.ts | 68 +- .../src/store/slices/preflight.test.ts | 84 + src/renderer/src/store/slices/preflight.ts | 8 +- .../slices/repos-project-runtime.test.ts | 227 +++ src/renderer/src/store/slices/repos.test.ts | 56 +- src/renderer/src/store/slices/repos.ts | 56 + .../src/store/slices/store-cascades.test.ts | 102 +- src/renderer/src/store/slices/terminals.ts | 27 +- src/shared/constants.test.ts | 6 + src/shared/constants.ts | 1 + src/shared/local-windows-terminal-runtime.ts | 101 ++ src/shared/project-execution-runtime.test.ts | 332 ++++ src/shared/project-execution-runtime.ts | 294 ++++ src/shared/secure-file.test.ts | 4 +- src/shared/skills.ts | 3 + src/shared/text-search.ts | 23 +- src/shared/types.ts | 14 + src/shared/wsl-login-shell-command.test.ts | 65 + src/shared/wsl-login-shell-command.ts | 13 +- .../e2e/windows-project-runtime-smoke.spec.ts | 184 +++ 333 files changed, 21039 insertions(+), 5507 deletions(-) create mode 100644 .gitattributes create mode 100644 src/main/azure-devops/azure-devops-api-request.ts create mode 100644 src/main/git/git-runtime-options.ts create mode 100644 src/main/github/client-pr-local-runtime.test.ts create mode 100644 src/main/github/gh-error-classification.ts create mode 100644 src/main/github/github-repository-identity.ts create mode 100644 src/main/gitlab/gitlab-project-ref-resolution.ts create mode 100644 src/main/gitlab/glab-error-classification.ts create mode 100644 src/main/gitlab/merge-request-creation-lookup.ts create mode 100644 src/main/ipc/local-worktree-runtime-options.ts create mode 100644 src/main/ipc/preflight-runtime-target.ts create mode 100644 src/main/ipc/skills.test.ts create mode 100644 src/main/local-project-runtime-resolution.ts create mode 100644 src/main/local-worktree-filesystem.test.ts create mode 100644 src/main/local-worktree-filesystem.ts create mode 100644 src/main/project-runtime-git-options.test.ts create mode 100644 src/main/project-runtime-git-options.ts create mode 100644 src/main/rate-limits/project-runtime-rate-limit-target.ts create mode 100644 src/main/source-control/forge-review-mappers.ts create mode 100644 src/main/source-control/hosted-review-git-options.ts create mode 100644 src/relay/git-handler-worktree-remove.ts create mode 100644 src/relay/relay-test-socket-path.ts delete mode 100644 src/renderer/src/components/settings/AgentLocationSetting.tsx create mode 100644 src/renderer/src/components/settings/CliSection.test.tsx create mode 100644 src/renderer/src/components/settings/ComputerUseSkillSetupPanel.tsx create mode 100644 src/renderer/src/components/settings/DefaultWindowsProjectRuntimeSetting.test.tsx create mode 100644 src/renderer/src/components/settings/DefaultWindowsProjectRuntimeSetting.tsx create mode 100644 src/renderer/src/components/settings/ProjectWindowsRuntimeSetting.test.tsx create mode 100644 src/renderer/src/components/settings/ProjectWindowsRuntimeSetting.tsx create mode 100644 src/renderer/src/components/settings/RepositoryWindowsRuntimeSection.tsx create mode 100644 src/renderer/src/components/settings/RepositoryWorktreeDefaultsSection.tsx create mode 100644 src/renderer/src/components/settings/TerminalAdvancedSection.tsx create mode 100644 src/renderer/src/components/settings/TerminalInteractionSection.tsx create mode 100644 src/renderer/src/components/settings/TerminalMacKeyboardSection.tsx create mode 100644 src/renderer/src/components/settings/TerminalRenderingSection.tsx create mode 100644 src/renderer/src/components/settings/TerminalSetupScriptSection.tsx create mode 100644 src/renderer/src/components/settings/TerminalWindowsShellSection.tsx create mode 100644 src/renderer/src/components/settings/general-project-runtime-search.ts create mode 100644 src/renderer/src/components/settings/repository-identity-search.ts create mode 100644 src/renderer/src/components/settings/repository-runtime-session-summary.ts create mode 100644 src/renderer/src/hooks/useActiveProjectSkillRuntime.ts create mode 100644 src/renderer/src/lib/ai-vault-resume-command.test.ts create mode 100644 src/renderer/src/lib/ai-vault-resume-command.ts create mode 100644 src/renderer/src/lib/launch-work-item-direct-draft.ts create mode 100644 src/renderer/src/lib/project-skill-runtime.test.ts create mode 100644 src/renderer/src/lib/project-skill-runtime.ts create mode 100644 src/renderer/src/lib/renderer-app-platform.ts create mode 100644 src/renderer/src/store/slices/repos-project-runtime.test.ts create mode 100644 src/shared/local-windows-terminal-runtime.ts create mode 100644 src/shared/project-execution-runtime.test.ts create mode 100644 src/shared/project-execution-runtime.ts create mode 100644 tests/e2e/windows-project-runtime-smoke.spec.ts diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000000..a556fee875f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +/config/scripts/create-draft-release.mjs text eol=lf +/config/scripts/orca-dev.mjs text eol=lf +/config/scripts/latest-stable-release.mjs text eol=lf +/config/scripts/publish-complete-draft-releases.mjs text eol=lf +/config/scripts/release-rc-history.mjs text eol=lf +/config/scripts/run-internal-dev-setup.mjs text eol=lf +/config/scripts/verify-cli-bin.mjs text eol=lf +/config/scripts/verify-release-required-assets.mjs text eol=lf diff --git a/config/scripts/ensure-native-runtime.mjs b/config/scripts/ensure-native-runtime.mjs index 1796b309532..3542d621093 100644 --- a/config/scripts/ensure-native-runtime.mjs +++ b/config/scripts/ensure-native-runtime.mjs @@ -319,7 +319,7 @@ function runPnpm(args) { const result = spawnSync(command, args, { cwd: projectDir, stdio: 'inherit', - shell: false + shell: process.platform === 'win32' }) if (result.error || result.status !== 0) { diff --git a/config/scripts/orca-dev-bin.test.mjs b/config/scripts/orca-dev-bin.test.mjs index 46ffc82ff4c..2b0e4f23f0f 100644 --- a/config/scripts/orca-dev-bin.test.mjs +++ b/config/scripts/orca-dev-bin.test.mjs @@ -11,7 +11,7 @@ const wrapperPath = path.join(projectDir, 'config', 'scripts', 'orca-dev.mjs') describe('orca-dev package bin', () => { it('uses a Node entrypoint for cross-platform package installs', () => { expect(packageJson.bin['orca-dev']).toBe('./config/scripts/orca-dev.mjs') - expect(readFileSync(wrapperPath, 'utf8').startsWith('#!/usr/bin/env node\n')).toBe(true) + expect(readFileSync(wrapperPath, 'utf8')).toMatch(/^#!\/usr\/bin\/env node\n/) }) it('runs the dev CLI through Node without requiring Bash', () => { diff --git a/config/scripts/rebuild-native-deps.test.mjs b/config/scripts/rebuild-native-deps.test.mjs index 23e7a80640f..4a68a25df28 100644 --- a/config/scripts/rebuild-native-deps.test.mjs +++ b/config/scripts/rebuild-native-deps.test.mjs @@ -226,13 +226,24 @@ function mkTempProject() { } function runRebuildScript(projectDir, extraEnv = {}) { + const env = { + ...process.env, + npm_config_platform: 'linux', + npm_config_arch: 'x64' + } + for (const key of Object.keys(env)) { + if ( + key.toLowerCase() === 'orca_strict_electron_install' || + key.toLowerCase() === 'npm_lifecycle_event' + ) { + delete env[key] + } + } return spawnSync(process.execPath, ['config/scripts/rebuild-native-deps.mjs'], { cwd: projectDir, encoding: 'utf8', env: { - ...process.env, - npm_config_platform: 'linux', - npm_config_arch: 'x64', + ...env, ...extraEnv } }) diff --git a/src/cli/runtime/environments.test.ts b/src/cli/runtime/environments.test.ts index 8c27060cdba..9344722c94e 100644 --- a/src/cli/runtime/environments.test.ts +++ b/src/cli/runtime/environments.test.ts @@ -21,6 +21,8 @@ function pairingCode(endpoint = 'ws://127.0.0.1:6768'): string { } describe('CLI runtime environments', () => { + const posixModeIt = process.platform === 'win32' ? it.skip : it + it('saves, resolves, and removes a paired environment', () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-env-store-')) const saved = addEnvironmentFromPairingCode(userDataPath, { @@ -37,13 +39,27 @@ describe('CLI runtime environments', () => { expect(resolveEnvironmentPairingOffer(userDataPath, saved.id)).toMatchObject({ endpoint: 'ws://127.0.0.1:6768' }) - expect((statSync(getEnvironmentStorePath(userDataPath)).mode & 0o777).toString(8)).toBe('600') + expect(statSync(getEnvironmentStorePath(userDataPath)).isFile()).toBe(true) const removed = removeEnvironment(userDataPath, 'workstation') expect(removed.id).toBe(saved.id) expect(listEnvironments(userDataPath)).toEqual([]) }) + posixModeIt('stores paired environments with owner-only POSIX permissions', () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-env-store-')) + + addEnvironmentFromPairingCode(userDataPath, { + name: 'workstation', + pairingCode: pairingCode(), + now: 100 + }) + + // Why: NTFS mode bits do not prove Windows ACL hardening; shared secure-file + // tests cover that path, while POSIX hosts must keep the token store at 0600. + expect((statSync(getEnvironmentStorePath(userDataPath)).mode & 0o777).toString(8)).toBe('600') + }) + it('rejects an environment with the same name', () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-env-store-')) const first = addEnvironmentFromPairingCode(userDataPath, { diff --git a/src/main/agent-hooks/installer-utils.ts b/src/main/agent-hooks/installer-utils.ts index dcdc596a5de..d62ed98395c 100644 --- a/src/main/agent-hooks/installer-utils.ts +++ b/src/main/agent-hooks/installer-utils.ts @@ -63,12 +63,20 @@ export function readHooksJson(configPath: string): HooksConfig | null { export function createManagedCommandMatcher( scriptFileName: string ): (command: string | undefined) => boolean { - const needle = `agent-hooks/${scriptFileName}` + const scriptStem = scriptFileName.replace(/\.(?:cmd|sh)$/, '') + // Why: local Windows installs use .cmd, while SSH/POSIX installs and older + // entries use .sh. A platform switch should still sweep stale Orca hooks. + const needles = [ + `agent-hooks/${scriptFileName}`, + `agent-hooks/${scriptStem}.cmd`, + `agent-hooks/${scriptStem}.sh` + ] return (command) => { if (!command) { return false } - return command.replaceAll('\\', '/').includes(needle) + const normalizedCommand = command.replaceAll('\\', '/') + return needles.some((needle) => normalizedCommand.includes(needle)) } } diff --git a/src/main/agent-trust-presets.test.ts b/src/main/agent-trust-presets.test.ts index 805be4227e2..9c7f9122389 100644 --- a/src/main/agent-trust-presets.test.ts +++ b/src/main/agent-trust-presets.test.ts @@ -140,7 +140,7 @@ describe('markCodexProjectTrusted', () => { it('writes ~/.codex/config.toml with the project marked trusted', () => { const workspace = mkdtempSync(join(tmpdir(), 'orca-codex-ws-')) try { - const realpath = realpathSync(workspace) + const realpath = realpathSync.native(workspace) markCodexProjectTrusted(workspace) const configPath = join(testState.fakeHomeDir, '.codex', 'config.toml') const runtimeConfigPath = join( @@ -164,7 +164,7 @@ describe('markCodexProjectTrusted', () => { it('preserves existing config keys and updates an existing project block', () => { const workspace = mkdtempSync(join(tmpdir(), 'orca-codex-ws-')) - const realpath = realpathSync(workspace) + const realpath = realpathSync.native(workspace) try { const codexDir = join(testState.fakeHomeDir, '.codex') const runtimeCodexDir = join(testState.userDataDir, 'codex-runtime-home', 'home') diff --git a/src/main/agent-trust-presets.ts b/src/main/agent-trust-presets.ts index 163c2183208..9ba45a35b33 100644 --- a/src/main/agent-trust-presets.ts +++ b/src/main/agent-trust-presets.ts @@ -124,7 +124,7 @@ function canonicalize(p: string): string { // (orca caches realpath()'d worktree paths) matches the agent's lookup. try { if (existsSync(p)) { - return realpathSync(p) + return realpathSync.native(p) } } catch { // Fall through to the raw input. @@ -134,6 +134,8 @@ function canonicalize(p: string): string { function cursorWorkspaceSlug(absPath: string): string { const stripped = absPath.replace(/^[\\/]+/, '') - const slug = stripped.replace(/[\\/]+/g, '-') + // Why: Windows absolute paths include characters such as ":" that cannot + // be used in the ~/.cursor/projects/ directory name. + const slug = stripped.replace(/[\\/:*?"<>|]+/g, '-') return slug } diff --git a/src/main/antigravity/hook-service.test.ts b/src/main/antigravity/hook-service.test.ts index e0e2ad9aed1..da4bbc7ab6b 100644 --- a/src/main/antigravity/hook-service.test.ts +++ b/src/main/antigravity/hook-service.test.ts @@ -17,6 +17,13 @@ vi.mock('os', async () => { import { AntigravityHookService } from './hook-service' +const ANTIGRAVITY_SCRIPT_FILE_NAME = + process.platform === 'win32' ? 'antigravity-hook.cmd' : 'antigravity-hook.sh' +const ANTIGRAVITY_PRE_INVOCATION_COMMAND = + process.platform === 'win32' ? 'antigravity-pre-invocation.cmd' : 'antigravity-hook.sh' +const ANTIGRAVITY_POST_TOOL_USE_COMMAND = + process.platform === 'win32' ? 'antigravity-post-tool-use.cmd' : 'antigravity-hook.sh' + function withPlatform(platform: NodeJS.Platform, run: () => T): T { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: platform }) @@ -62,21 +69,33 @@ describe('AntigravityHookService', () => { ) expect(config['orca-status'].PreToolUse).toBeUndefined() expect(config['orca-status'].PostToolUse[0].matcher).toBe('*') - expect(config['orca-status'].PreInvocation[0].command).toContain('antigravity-hook') expect(config['orca-status'].PreInvocation[0].command).toContain( - "ORCA_ANTIGRAVITY_EVENT='PreInvocation'" + ANTIGRAVITY_PRE_INVOCATION_COMMAND ) - expect(config['orca-status'].Stop[0].command).toContain("ORCA_ANTIGRAVITY_EVENT='Stop'") + if (process.platform === 'win32') { + expect(config['orca-status'].PreInvocation[0].command).not.toContain('ORCA_ANTIGRAVITY_EVENT') + } else { + expect(config['orca-status'].PreInvocation[0].command).toContain( + "ORCA_ANTIGRAVITY_EVENT='PreInvocation'" + ) + expect(config['orca-status'].Stop[0].command).toContain("ORCA_ANTIGRAVITY_EVENT='Stop'") + } const script = readFileSync( - join(homeDir, '.orca', 'agent-hooks', 'antigravity-hook.sh'), + join(homeDir, '.orca', 'agent-hooks', ANTIGRAVITY_SCRIPT_FILE_NAME), 'utf8' ) expect(script).toContain('/hook/antigravity') - expect(script).toContain('hook_event_name=${ORCA_ANTIGRAVITY_EVENT}') - expect(script).toContain('payload=$(cat)') - expect(script).toContain("payload='{}'") - expect(script).not.toContain('if [ -z "$payload" ]; then\n exit 0\nfi') + if (process.platform === 'win32') { + expect(script).toContain('hook_event_name=$env:ORCA_ANTIGRAVITY_EVENT') + expect(script).toContain('[string]::IsNullOrWhiteSpace($inputData)) { @{} }') + expect(script).not.toContain('[string]::IsNullOrWhiteSpace($inputData)) { exit 0 }') + } else { + expect(script).toContain('hook_event_name=${ORCA_ANTIGRAVITY_EVENT}') + expect(script).toContain('payload=$(cat)') + expect(script).toContain("payload='{}'") + expect(script).not.toContain('if [ -z "$payload" ]; then\n exit 0\nfi') + } expect(script).toContain('{"decision":""}') }) @@ -189,7 +208,9 @@ describe('AntigravityHookService', () => { expect(config['user-hook'].PreInvocation[0].command).toBe('/usr/local/bin/user-hook') const commands = config['orca-status'].PreInvocation.map((entry) => entry.command) expect(commands).toContain('/usr/local/bin/orca-extra') - expect(commands.some((command) => command.includes('antigravity-hook.sh'))).toBe(true) + expect(commands.some((command) => command.includes(ANTIGRAVITY_PRE_INVOCATION_COMMAND))).toBe( + true + ) }) it('removes stale managed Antigravity hook entries from retired events', () => { @@ -230,6 +251,8 @@ describe('AntigravityHookService', () => { (definition.hooks ?? []).map((hook) => hook.command) ) expect(commands).toHaveLength(1) - expect(commands[0]).toContain(join(homeDir, '.orca', 'agent-hooks', 'antigravity-hook.sh')) + expect(commands[0]).toContain( + join(homeDir, '.orca', 'agent-hooks', ANTIGRAVITY_POST_TOOL_USE_COMMAND) + ) }) }) diff --git a/src/main/attribution/terminal-attribution.test.ts b/src/main/attribution/terminal-attribution.test.ts index 1686cbe0ed0..a051d04ca7d 100644 --- a/src/main/attribution/terminal-attribution.test.ts +++ b/src/main/attribution/terminal-attribution.test.ts @@ -18,6 +18,9 @@ import { applyTerminalAttributionEnv, resolveAttributionShellFamily } from './te describe('applyTerminalAttributionEnv', () => { let tmpRoot: string | null = null + // Why: these subprocess fixtures create extensionless Bash fake commands; + // native Windows command resolution is covered by wrapper/PATH assertions below. + const posixSubprocessIt = process.platform === 'win32' ? it.skip : it afterEach(() => { if (tmpRoot) { @@ -81,7 +84,7 @@ describe('applyTerminalAttributionEnv', () => { ) }) - it('does not amend HEAD when git commit --dry-run exits successfully', () => { + posixSubprocessIt('does not amend HEAD when git commit --dry-run exits successfully', () => { const root = makeTmpRoot() const repo = join(root, 'repo') mkdirSync(repo) @@ -115,7 +118,7 @@ describe('applyTerminalAttributionEnv', () => { ) }) - it('still adds the trailer when git commit uses --no-verify shorthand', () => { + posixSubprocessIt('still adds the trailer when git commit uses --no-verify shorthand', () => { const root = makeTmpRoot() const repo = join(root, 'repo') mkdirSync(repo) @@ -138,7 +141,7 @@ describe('applyTerminalAttributionEnv', () => { ) }) - it('adds the trailer when git commit uses combined -am shorthand', () => { + posixSubprocessIt('adds the trailer when git commit uses combined -am shorthand', () => { const root = makeTmpRoot() const repo = join(root, 'repo') mkdirSync(repo) @@ -163,7 +166,7 @@ describe('applyTerminalAttributionEnv', () => { ) }) - it('adds the trailer when git commit follows global git config args', () => { + posixSubprocessIt('adds the trailer when git commit follows global git config args', () => { const root = makeTmpRoot() const repo = join(root, 'repo') mkdirSync(repo) @@ -186,7 +189,7 @@ describe('applyTerminalAttributionEnv', () => { ) }) - it('adds the trailer to commit message files before git runs', () => { + posixSubprocessIt('adds the trailer to commit message files before git runs', () => { const root = makeTmpRoot() const repo = join(root, 'repo') const messagePath = join(root, 'message.txt') @@ -212,14 +215,16 @@ describe('applyTerminalAttributionEnv', () => { expect(readFileSync(messagePath, 'utf8')).toBe('initial from file\n') }) - it('passes missing commit message files through without adding fallback message args', () => { - const root = makeTmpRoot() - const binDir = join(root, 'bin') - const argsPath = join(root, 'commit-args') - mkdirSync(binDir) - writeFileSync( - join(binDir, 'git'), - `#!/usr/bin/env bash + posixSubprocessIt( + 'passes missing commit message files through without adding fallback message args', + () => { + const root = makeTmpRoot() + const binDir = join(root, 'bin') + const argsPath = join(root, 'commit-args') + mkdirSync(binDir) + writeFileSync( + join(binDir, 'git'), + `#!/usr/bin/env bash set -euo pipefail if [[ "$1" == "commit" ]]; then printf '%s\\n' "$@" >"${argsPath}" @@ -227,38 +232,41 @@ if [[ "$1" == "commit" ]]; then fi exit 1 `, - 'utf8' - ) - chmodSync(join(binDir, 'git'), 0o755) + 'utf8' + ) + chmodSync(join(binDir, 'git'), 0o755) - const attributionEnv = { - PATH: `${binDir}:${stripInheritedAttributionPath(process.env.PATH ?? '')}` - } - applyTerminalAttributionEnv(attributionEnv, { - enabled: true, - userDataPath: join(root, 'user-data') - }) - - expect(() => - execFileSync('git', ['commit', '-F', join(root, 'missing-message.txt')], { - encoding: 'utf8', - env: cleanAttributionEnv(attributionEnv) + const attributionEnv = { + PATH: `${binDir}:${stripInheritedAttributionPath(process.env.PATH ?? '')}` + } + applyTerminalAttributionEnv(attributionEnv, { + enabled: true, + userDataPath: join(root, 'user-data') }) - ).toThrow() - expect(readFileSync(argsPath, 'utf8')).not.toContain('Co-authored-by: Orca') - }) + expect(() => + execFileSync('git', ['commit', '-F', join(root, 'missing-message.txt')], { + encoding: 'utf8', + env: cleanAttributionEnv(attributionEnv) + }) + ).toThrow() - it('passes reuse and fixup commit message modes through without attribution', () => { - const root = makeTmpRoot() - const binDir = join(root, 'bin') - const argsPath = join(root, 'commit-args') - const messagePath = join(root, 'message.txt') - mkdirSync(binDir) - writeFileSync(messagePath, 'from file\n') - writeFileSync( - join(binDir, 'git'), - `#!/usr/bin/env bash + expect(readFileSync(argsPath, 'utf8')).not.toContain('Co-authored-by: Orca') + } + ) + + posixSubprocessIt( + 'passes reuse and fixup commit message modes through without attribution', + () => { + const root = makeTmpRoot() + const binDir = join(root, 'bin') + const argsPath = join(root, 'commit-args') + const messagePath = join(root, 'message.txt') + mkdirSync(binDir) + writeFileSync(messagePath, 'from file\n') + writeFileSync( + join(binDir, 'git'), + `#!/usr/bin/env bash set -euo pipefail if [[ "$1" == "commit" ]]; then printf '%s\\n' "$@" >>"${argsPath}" @@ -266,35 +274,36 @@ if [[ "$1" == "commit" ]]; then fi exit 1 `, - 'utf8' - ) - chmodSync(join(binDir, 'git'), 0o755) + 'utf8' + ) + chmodSync(join(binDir, 'git'), 0o755) - const attributionEnv = { - PATH: `${binDir}:${stripInheritedAttributionPath(process.env.PATH ?? '')}` + const attributionEnv = { + PATH: `${binDir}:${stripInheritedAttributionPath(process.env.PATH ?? '')}` + } + applyTerminalAttributionEnv(attributionEnv, { + enabled: true, + userDataPath: join(root, 'user-data') + }) + + execFileSync('git', ['commit', '-C', 'HEAD'], { + encoding: 'utf8', + env: cleanAttributionEnv(attributionEnv) + }) + execFileSync('git', ['commit', '--fixup', 'HEAD'], { + encoding: 'utf8', + env: cleanAttributionEnv(attributionEnv) + }) + execFileSync('git', ['commit', '-F', messagePath, '--fixup', 'HEAD'], { + encoding: 'utf8', + env: cleanAttributionEnv(attributionEnv) + }) + + expect(readFileSync(argsPath, 'utf8')).not.toContain('Co-authored-by: Orca') } - applyTerminalAttributionEnv(attributionEnv, { - enabled: true, - userDataPath: join(root, 'user-data') - }) + ) - execFileSync('git', ['commit', '-C', 'HEAD'], { - encoding: 'utf8', - env: cleanAttributionEnv(attributionEnv) - }) - execFileSync('git', ['commit', '--fixup', 'HEAD'], { - encoding: 'utf8', - env: cleanAttributionEnv(attributionEnv) - }) - execFileSync('git', ['commit', '-F', messagePath, '--fixup', 'HEAD'], { - encoding: 'utf8', - env: cleanAttributionEnv(attributionEnv) - }) - - expect(readFileSync(argsPath, 'utf8')).not.toContain('Co-authored-by: Orca') - }) - - it('adds the trailer before commit-msg hooks validate the commit', () => { + posixSubprocessIt('adds the trailer before commit-msg hooks validate the commit', () => { const root = makeTmpRoot() const repo = join(root, 'repo') mkdirSync(repo) @@ -334,7 +343,7 @@ grep -Fq 'Co-authored-by: Orca ' "$1" ) }) - it('adds git attribution to the original commit command without amending', () => { + posixSubprocessIt('adds git attribution to the original commit command without amending', () => { const root = makeTmpRoot() const binDir = join(root, 'bin') const commitPath = join(root, 'commit-called') @@ -382,7 +391,7 @@ exit 1 expect(readFileSync(argsPath, 'utf8')).toContain('Co-authored-by: Orca ') }) - it('passes editor-based commits through without attribution', () => { + posixSubprocessIt('passes editor-based commits through without attribution', () => { const root = makeTmpRoot() const binDir = join(root, 'bin') const argsPath = join(root, 'commit-args') @@ -417,7 +426,7 @@ exit 1 expect(readFileSync(argsPath, 'utf8')).toBe('commit\n') }) - it('preserves interactive gh pr create without guessing which PR to edit', () => { + posixSubprocessIt('preserves interactive gh pr create without guessing which PR to edit', () => { const root = makeTmpRoot() const binDir = join(root, 'bin') const markerPath = join(root, 'gh-edit-called') @@ -464,7 +473,7 @@ exit 1 expect(existsSync(markerPath)).toBe(false) }) - it('adds gh attribution for noninteractive create output URLs', () => { + posixSubprocessIt('adds gh attribution for noninteractive create output URLs', () => { const root = makeTmpRoot() const binDir = join(root, 'bin') const prMarkerPath = join(root, 'pr-edit-called') @@ -532,7 +541,7 @@ exit 1 expect(readFileSync(patchArgsPath, 'utf8')).not.toContain('PR body') }) - it('passes gh create help through without editing existing PRs or issues', () => { + posixSubprocessIt('passes gh create help through without editing existing PRs or issues', () => { const root = makeTmpRoot() const binDir = join(root, 'bin') const markerPath = join(root, 'gh-edit-called') @@ -593,14 +602,16 @@ exit 1 expect(existsSync(markerPath)).toBe(false) }) - it('preserves interactive gh issue create without guessing which issue to edit', () => { - const root = makeTmpRoot() - const binDir = join(root, 'bin') - const markerPath = join(root, 'gh-edit-called') - mkdirSync(binDir) - writeFileSync( - join(binDir, 'gh'), - `#!/usr/bin/env bash + posixSubprocessIt( + 'preserves interactive gh issue create without guessing which issue to edit', + () => { + const root = makeTmpRoot() + const binDir = join(root, 'bin') + const markerPath = join(root, 'gh-edit-called') + mkdirSync(binDir) + writeFileSync( + join(binDir, 'gh'), + `#!/usr/bin/env bash set -euo pipefail if [[ "$1 $2" == "issue create" ]]; then printf '%s\\n' 'interactive issue create complete' @@ -616,27 +627,28 @@ if [[ "$1 $2 $3 $4" == "api -X PATCH repos/stablyai/orca/issues/456" ]]; then fi exit 1 `, - 'utf8' - ) - chmodSync(join(binDir, 'gh'), 0o755) - const attributionEnv = { - PATH: `${binDir}:${stripInheritedAttributionPath(process.env.PATH ?? '')}` + 'utf8' + ) + chmodSync(join(binDir, 'gh'), 0o755) + const attributionEnv = { + PATH: `${binDir}:${stripInheritedAttributionPath(process.env.PATH ?? '')}` + } + applyTerminalAttributionEnv(attributionEnv, { + enabled: true, + userDataPath: join(root, 'user-data') + }) + + const output = execFileSync('gh', ['issue', 'create'], { + encoding: 'utf8', + env: cleanAttributionEnv(attributionEnv) + }) + + expect(output).toBe('interactive issue create complete\n') + expect(existsSync(markerPath)).toBe(false) } - applyTerminalAttributionEnv(attributionEnv, { - enabled: true, - userDataPath: join(root, 'user-data') - }) + ) - const output = execFileSync('gh', ['issue', 'create'], { - encoding: 'utf8', - env: cleanAttributionEnv(attributionEnv) - }) - - expect(output).toBe('interactive issue create complete\n') - expect(existsSync(markerPath)).toBe(false) - }) - - it('skips gh attribution edits when viewing the created item fails', () => { + posixSubprocessIt('skips gh attribution edits when viewing the created item fails', () => { const root = makeTmpRoot() const binDir = join(root, 'bin') const markerPath = join(root, 'gh-edit-called') @@ -678,7 +690,7 @@ exit 1 expect(existsSync(markerPath)).toBe(false) }) - it('keeps gh create successful when the attribution edit fails', () => { + posixSubprocessIt('keeps gh create successful when the attribution edit fails', () => { const root = makeTmpRoot() const binDir = join(root, 'bin') mkdirSync(binDir) diff --git a/src/main/automations/precheck-runner.ts b/src/main/automations/precheck-runner.ts index fa9a452086f..753bd784b06 100644 --- a/src/main/automations/precheck-runner.ts +++ b/src/main/automations/precheck-runner.ts @@ -171,7 +171,7 @@ function runLocalPrecheck( }) child.on('close', (code) => { settle( - typeof code === 'number' ? code : null, + timedOut || typeof code !== 'number' ? null : code, timedOut ? `Precheck timed out after ${precheck.timeoutSeconds}s.` : null ) }) diff --git a/src/main/azure-devops/azure-devops-api-request.ts b/src/main/azure-devops/azure-devops-api-request.ts new file mode 100644 index 00000000000..b8e5df51bde --- /dev/null +++ b/src/main/azure-devops/azure-devops-api-request.ts @@ -0,0 +1,105 @@ +import { Buffer } from 'buffer' +import type { AzureDevOpsRepoRef } from './repository-ref' + +const REQUEST_TIMEOUT_MS = 5000 + +type AzureDevOpsAuthConfig = { + apiBaseUrl: string | null + pat: string | null + accessToken: string | null + username: string | null +} + +export type AzureDevOpsRequestOptions = { + searchParams?: Record + timeoutMs?: number +} + +function envValue(name: string): string | null { + const value = process.env[name]?.trim() ?? '' + return value.length > 0 ? value : null +} + +export function normalizeAzureDevOpsApiBaseUrl(value: string): string { + return value + .trim() + .replace(/\/+$/, '') + .replace(/\/_apis$/i, '') +} + +export function getAzureDevOpsAuthConfig(): AzureDevOpsAuthConfig { + return { + apiBaseUrl: envValue('ORCA_AZURE_DEVOPS_API_BASE_URL'), + pat: envValue('ORCA_AZURE_DEVOPS_TOKEN') ?? envValue('ORCA_AZURE_DEVOPS_PAT'), + accessToken: envValue('ORCA_AZURE_DEVOPS_ACCESS_TOKEN'), + username: envValue('ORCA_AZURE_DEVOPS_USERNAME') + } +} + +export function azureDevOpsTokenConfigured(config: AzureDevOpsAuthConfig): boolean { + return Boolean(config.pat || config.accessToken) +} + +function authHeaders(config: AzureDevOpsAuthConfig): Record { + if (config.accessToken) { + return { Authorization: `Bearer ${config.accessToken}` } + } + if (config.pat) { + const encoded = Buffer.from(`${config.username ?? ''}:${config.pat}`).toString('base64') + return { Authorization: `Basic ${encoded}` } + } + return {} +} + +function configuredApiBaseUrl(repo: AzureDevOpsRepoRef): string { + const configured = getAzureDevOpsAuthConfig().apiBaseUrl + return configured ? normalizeAzureDevOpsApiBaseUrl(configured) : repo.apiBaseUrl +} + +function apiUrl( + baseUrl: string, + path: string, + searchParams?: AzureDevOpsRequestOptions['searchParams'] +): URL { + const url = new URL(`${baseUrl.replace(/\/+$/, '')}${path}`) + const params = { ...searchParams, 'api-version': searchParams?.['api-version'] ?? '7.1' } + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, String(value)) + } + return url +} + +export async function requestAzureDevOpsJsonAtBase( + baseUrl: string, + path: string, + options: AzureDevOpsRequestOptions = {} +): Promise { + const config = getAzureDevOpsAuthConfig() + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? REQUEST_TIMEOUT_MS) + try { + const response = await fetch(apiUrl(baseUrl, path, options.searchParams), { + headers: { + Accept: 'application/json', + ...authHeaders(config) + }, + signal: controller.signal + }) + if (!response.ok) { + return null + } + return (await response.json()) as T + } catch { + return null + } finally { + clearTimeout(timeout) + } +} + +export function requestAzureDevOpsJson( + repo: AzureDevOpsRepoRef, + path: string, + options: AzureDevOpsRequestOptions = {} +): Promise { + return requestAzureDevOpsJsonAtBase(configuredApiBaseUrl(repo), path, options) +} diff --git a/src/main/azure-devops/client.ts b/src/main/azure-devops/client.ts index 2914772db9a..2bed23c2cd4 100644 --- a/src/main/azure-devops/client.ts +++ b/src/main/azure-devops/client.ts @@ -1,4 +1,3 @@ -import { Buffer } from 'buffer' import { deriveAzureDevOpsStatus, mapAzureDevOpsPullRequest, @@ -7,15 +6,18 @@ import { type RawAzureDevOpsStatus } from './pull-request-mappers' import { getAzureDevOpsRepoRef, type AzureDevOpsRepoRef } from './repository-ref' - -const REQUEST_TIMEOUT_MS = 5000 - -type AzureDevOpsAuthConfig = { - apiBaseUrl: string | null - pat: string | null - accessToken: string | null - username: string | null -} +import { + getHostedReviewLocalGitOptions, + type HostedReviewExecutionOptions +} from '../source-control/hosted-review-git-options' +import { + azureDevOpsTokenConfigured, + getAzureDevOpsAuthConfig, + normalizeAzureDevOpsApiBaseUrl, + requestAzureDevOpsJson, + requestAzureDevOpsJsonAtBase +} from './azure-devops-api-request' +export { normalizeAzureDevOpsApiBaseUrl } from './azure-devops-api-request' export type AzureDevOpsAuthStatus = { configured: boolean @@ -25,11 +27,6 @@ export type AzureDevOpsAuthStatus = { tokenConfigured: boolean } -type RequestOptions = { - searchParams?: Record - timeoutMs?: number -} - type RawAzureDevOpsRepository = { id?: string | null name?: string | null @@ -41,91 +38,6 @@ type RawAzureDevOpsRepository = { } | null } -function envValue(name: string): string | null { - const value = process.env[name]?.trim() ?? '' - return value.length > 0 ? value : null -} - -export function normalizeAzureDevOpsApiBaseUrl(value: string): string { - return value - .trim() - .replace(/\/+$/, '') - .replace(/\/_apis$/i, '') -} - -function getAuthConfig(): AzureDevOpsAuthConfig { - return { - apiBaseUrl: envValue('ORCA_AZURE_DEVOPS_API_BASE_URL'), - pat: envValue('ORCA_AZURE_DEVOPS_TOKEN') ?? envValue('ORCA_AZURE_DEVOPS_PAT'), - accessToken: envValue('ORCA_AZURE_DEVOPS_ACCESS_TOKEN'), - username: envValue('ORCA_AZURE_DEVOPS_USERNAME') - } -} - -function tokenConfigured(config: AzureDevOpsAuthConfig): boolean { - return Boolean(config.pat || config.accessToken) -} - -function authHeaders(config: AzureDevOpsAuthConfig): Record { - if (config.accessToken) { - return { Authorization: `Bearer ${config.accessToken}` } - } - if (config.pat) { - const encoded = Buffer.from(`${config.username ?? ''}:${config.pat}`).toString('base64') - return { Authorization: `Basic ${encoded}` } - } - return {} -} - -function configuredApiBaseUrl(repo: AzureDevOpsRepoRef): string { - const configured = getAuthConfig().apiBaseUrl - return configured ? normalizeAzureDevOpsApiBaseUrl(configured) : repo.apiBaseUrl -} - -function apiUrl(baseUrl: string, path: string, searchParams?: RequestOptions['searchParams']): URL { - const url = new URL(`${baseUrl.replace(/\/+$/, '')}${path}`) - const params = { ...searchParams, 'api-version': searchParams?.['api-version'] ?? '7.1' } - for (const [key, value] of Object.entries(params)) { - url.searchParams.set(key, String(value)) - } - return url -} - -async function requestJsonAtBase( - baseUrl: string, - path: string, - options: RequestOptions = {} -): Promise { - const config = getAuthConfig() - const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? REQUEST_TIMEOUT_MS) - try { - const response = await fetch(apiUrl(baseUrl, path, options.searchParams), { - headers: { - Accept: 'application/json', - ...authHeaders(config) - }, - signal: controller.signal - }) - if (!response.ok) { - return null - } - return (await response.json()) as T - } catch { - return null - } finally { - clearTimeout(timeout) - } -} - -function requestJson( - repo: AzureDevOpsRepoRef, - path: string, - options: RequestOptions = {} -): Promise { - return requestJsonAtBase(configuredApiBaseUrl(repo), path, options) -} - function encodePathSegment(value: string): string { return encodeURIComponent(value) } @@ -133,7 +45,7 @@ function encodePathSegment(value: string): string { async function getRepository( repo: AzureDevOpsRepoRef ): Promise<{ idOrName: string; webBaseUrl: string } | null> { - const raw = await requestJson( + const raw = await requestAzureDevOpsJson( repo, `/_apis/git/repositories/${encodePathSegment(repo.repository)}` ) @@ -160,7 +72,9 @@ async function getPullRequestStatuses( repoIdOrName: string, pr: RawAzureDevOpsPullRequest ): Promise { - const raw = await requestJson( + const raw = await requestAzureDevOpsJson< + RawAzureDevOpsStatus[] | { value?: RawAzureDevOpsStatus[] } + >( repo, `/_apis/git/repositories/${encodePathSegment(repoIdOrName)}/pullRequests/${encodePathSegment( String(pr.pullRequestId) @@ -174,7 +88,7 @@ async function getPullRequestStatuses( if (!commitId) { return pr.statuses ?? [] } - const commitStatuses = await requestJson< + const commitStatuses = await requestAzureDevOpsJson< RawAzureDevOpsStatus[] | { value?: RawAzureDevOpsStatus[] } >( repo, @@ -215,9 +129,9 @@ function sortPullRequestsForBranch( } export async function getAzureDevOpsAuthStatus(): Promise { - const config = getAuthConfig() + const config = getAzureDevOpsAuthConfig() const baseUrl = config.apiBaseUrl ? normalizeAzureDevOpsApiBaseUrl(config.apiBaseUrl) : null - const hasToken = tokenConfigured(config) + const hasToken = azureDevOpsTokenConfigured(config) if (!baseUrl && !hasToken) { return { configured: false, @@ -237,7 +151,7 @@ export async function getAzureDevOpsAuthStatus(): Promise } } - const connection = await requestJsonAtBase<{ + const connection = await requestAzureDevOpsJsonAtBase<{ authenticatedUser?: { providerDisplayName?: string | null customDisplayName?: string | null @@ -257,14 +171,19 @@ export async function getAzureDevOpsAuthStatus(): Promise export async function getAzureDevOpsPullRequest( repoPath: string, prNumber: number, - connectionId?: string | null + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} ): Promise { - const repo = await getAzureDevOpsRepoRef(repoPath, connectionId) + const repo = await getAzureDevOpsRepoRef( + repoPath, + connectionId, + getHostedReviewLocalGitOptions(options) + ) const repository = repo ? await getRepository(repo) : null if (!repo || !repository) { return null } - const raw = await requestJson( + const raw = await requestAzureDevOpsJson( repo, `/_apis/git/repositories/${encodePathSegment(repository.idOrName)}/pullRequests/${encodePathSegment( String(prNumber) @@ -277,21 +196,26 @@ export async function getAzureDevOpsPullRequestForBranch( repoPath: string, branch: string, linkedPRNumber?: number | null, - connectionId?: string | null + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} ): Promise { const branchName = branch.replace(/^refs\/heads\//, '') if (!branchName && linkedPRNumber == null) { return null } - const repo = await getAzureDevOpsRepoRef(repoPath, connectionId) + const repo = await getAzureDevOpsRepoRef( + repoPath, + connectionId, + getHostedReviewLocalGitOptions(options) + ) const repository = repo ? await getRepository(repo) : null if (!repo || !repository) { return null } if (branchName) { - const list = await requestJson<{ value?: RawAzureDevOpsPullRequest[] }>( + const list = await requestAzureDevOpsJson<{ value?: RawAzureDevOpsPullRequest[] }>( repo, `/_apis/git/repositories/${encodePathSegment(repository.idOrName)}/pullRequests`, { @@ -311,7 +235,7 @@ export async function getAzureDevOpsPullRequestForBranch( if (typeof linkedPRNumber !== 'number') { return null } - const raw = await requestJson( + const raw = await requestAzureDevOpsJson( repo, `/_apis/git/repositories/${encodePathSegment(repository.idOrName)}/pullRequests/${encodePathSegment( String(linkedPRNumber) @@ -322,7 +246,8 @@ export async function getAzureDevOpsPullRequestForBranch( export async function getAzureDevOpsRepoSlug( repoPath: string, - connectionId?: string | null + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} ): Promise { - return getAzureDevOpsRepoRef(repoPath, connectionId) + return getAzureDevOpsRepoRef(repoPath, connectionId, getHostedReviewLocalGitOptions(options)) } diff --git a/src/main/azure-devops/repository-ref.test.ts b/src/main/azure-devops/repository-ref.test.ts index 92e738afbe0..7eaed82a702 100644 --- a/src/main/azure-devops/repository-ref.test.ts +++ b/src/main/azure-devops/repository-ref.test.ts @@ -1,9 +1,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { sshExecMock } = vi.hoisted(() => ({ +const { gitExecFileAsyncMock, sshExecMock } = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn(), sshExecMock: vi.fn() })) +vi.mock('../git/runner', () => ({ + gitExecFileAsync: gitExecFileAsyncMock +})) + import { _getAzureDevOpsRepoRefCacheSize, _resetAzureDevOpsRepoRefCache, @@ -14,6 +19,7 @@ import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/s describe('parseAzureDevOpsRepoRef', () => { beforeEach(() => { + gitExecFileAsyncMock.mockReset() sshExecMock.mockReset() unregisterSshGitProvider('conn-1') _resetAzureDevOpsRepoRefCache() @@ -112,6 +118,45 @@ describe('parseAzureDevOpsRepoRef', () => { }) expect(sshExecMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/repo') + expect(gitExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('keeps local host and local WSL repository-ref cache entries separate', async () => { + gitExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: 'https://dev.azure.com/acme/Host/_git/repo.git\n', + stderr: '' + }) + .mockResolvedValueOnce({ + stdout: 'https://dev.azure.com/acme/Wsl/_git/repo.git\n', + stderr: '' + }) + + await expect(getAzureDevOpsRepoRefForRemote('/repo', 'origin')).resolves.toMatchObject({ + project: 'Host', + repository: 'repo' + }) + await expect( + getAzureDevOpsRepoRefForRemote('/repo', 'origin', null, { wslDistro: 'Ubuntu' }) + ).resolves.toMatchObject({ + project: 'Wsl', + repository: 'repo' + }) + await expect( + getAzureDevOpsRepoRefForRemote('/repo', 'origin', null, { wslDistro: 'Ubuntu' }) + ).resolves.toMatchObject({ + project: 'Wsl', + repository: 'repo' + }) + + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) + expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(1, ['remote', 'get-url', 'origin'], { + cwd: '/repo' + }) + expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(2, ['remote', 'get-url', 'origin'], { + cwd: '/repo', + wslDistro: 'Ubuntu' + }) }) it('bounds cached repository refs for distinct repo paths', async () => { diff --git a/src/main/azure-devops/repository-ref.ts b/src/main/azure-devops/repository-ref.ts index 56baaa4e87a..868d0d8c9bc 100644 --- a/src/main/azure-devops/repository-ref.ts +++ b/src/main/azure-devops/repository-ref.ts @@ -10,6 +10,10 @@ export type AzureDevOpsRepoRef = { organization?: string | null } +type LocalGitExecOptions = { + wslDistro?: string +} + const REPO_REF_CACHE_MAX_ENTRIES = 512 const repoRefCache = new Map() @@ -204,9 +208,11 @@ export function parseAzureDevOpsRepoRef(remoteUrl: string): AzureDevOpsRepoRef | export async function getAzureDevOpsRepoRefForRemote( repoPath: string, remoteName: string, - connectionId?: string | null + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} ): Promise { - const cacheKey = `${connectionId ?? 'local'}\0${repoPath}\0${remoteName}` + const runtimeKey = connectionId ?? `local:${localGitOptions.wslDistro ?? 'host'}` + const cacheKey = `${runtimeKey}\0${repoPath}\0${remoteName}` if (repoRefCache.has(cacheKey)) { return repoRefCache.get(cacheKey)! } @@ -218,7 +224,8 @@ export async function getAzureDevOpsRepoRefForRemote( const { stdout } = sshGitProvider ? await sshGitProvider.exec(['remote', 'get-url', remoteName], repoPath) : await gitExecFileAsync(['remote', 'get-url', remoteName], { - cwd: repoPath + cwd: repoPath, + ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) }) const result = parseAzureDevOpsRepoRef(stdout) rememberRepoRefCacheEntry(cacheKey, result) @@ -236,7 +243,8 @@ export async function getAzureDevOpsRepoRefForRemote( export async function getAzureDevOpsRepoRef( repoPath: string, - connectionId?: string | null + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} ): Promise { - return getAzureDevOpsRepoRefForRemote(repoPath, 'origin', connectionId) + return getAzureDevOpsRepoRefForRemote(repoPath, 'origin', connectionId, localGitOptions) } diff --git a/src/main/bitbucket/client.ts b/src/main/bitbucket/client.ts index d07c43b62ed..d3062254582 100644 --- a/src/main/bitbucket/client.ts +++ b/src/main/bitbucket/client.ts @@ -8,6 +8,10 @@ import { type RawBitbucketPullRequest } from './pull-request-mappers' import { getBitbucketRepoRef, type BitbucketRepoRef } from './repository-ref' +import { + getHostedReviewLocalGitOptions, + type HostedReviewExecutionOptions +} from '../source-control/hosted-review-git-options' const DEFAULT_API_BASE_URL = 'https://api.bitbucket.org/2.0' const REQUEST_TIMEOUT_MS = 5000 @@ -160,9 +164,14 @@ export async function getBitbucketAuthStatus(): Promise { export async function getBitbucketPullRequest( repoPath: string, prNumber: number, - connectionId?: string | null + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} ): Promise { - const repo = await getBitbucketRepoRef(repoPath, connectionId) + const repo = await getBitbucketRepoRef( + repoPath, + connectionId, + getHostedReviewLocalGitOptions(options) + ) if (!repo) { return null } @@ -176,14 +185,19 @@ export async function getBitbucketPullRequestForBranch( repoPath: string, branch: string, linkedPRNumber?: number | null, - connectionId?: string | null + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} ): Promise { const branchName = branch.replace(/^refs\/heads\//, '') if (!branchName && linkedPRNumber == null) { return null } - const repo = await getBitbucketRepoRef(repoPath, connectionId) + const repo = await getBitbucketRepoRef( + repoPath, + connectionId, + getHostedReviewLocalGitOptions(options) + ) if (!repo) { return null } @@ -221,7 +235,8 @@ export async function getBitbucketPullRequestForBranch( export async function getBitbucketRepoSlug( repoPath: string, - connectionId?: string | null + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} ): Promise { - return getBitbucketRepoRef(repoPath, connectionId) + return getBitbucketRepoRef(repoPath, connectionId, getHostedReviewLocalGitOptions(options)) } diff --git a/src/main/bitbucket/repository-ref.test.ts b/src/main/bitbucket/repository-ref.test.ts index bc277b8326e..c29b3539927 100644 --- a/src/main/bitbucket/repository-ref.test.ts +++ b/src/main/bitbucket/repository-ref.test.ts @@ -94,6 +94,40 @@ describe('Bitbucket repository refs', () => { }) }) + it('keeps local host and local WSL repository-ref cache entries separate', async () => { + gitExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: 'git@bitbucket.org:host/project.git\n', + stderr: '' + }) + .mockResolvedValueOnce({ + stdout: 'git@bitbucket.org:wsl/project.git\n', + stderr: '' + }) + + await expect(getBitbucketRepoRef('/repo')).resolves.toEqual({ + workspace: 'host', + repoSlug: 'project' + }) + await expect(getBitbucketRepoRef('/repo', null, { wslDistro: 'Ubuntu' })).resolves.toEqual({ + workspace: 'wsl', + repoSlug: 'project' + }) + await expect(getBitbucketRepoRef('/repo', null, { wslDistro: 'Ubuntu' })).resolves.toEqual({ + workspace: 'wsl', + repoSlug: 'project' + }) + + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) + expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(1, ['remote', 'get-url', 'origin'], { + cwd: '/repo' + }) + expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(2, ['remote', 'get-url', 'origin'], { + cwd: '/repo', + wslDistro: 'Ubuntu' + }) + }) + it('bounds cached repository refs for distinct repo paths', async () => { gitExecFileAsyncMock.mockResolvedValue({ stdout: 'git@bitbucket.org:team/project.git\n', diff --git a/src/main/bitbucket/repository-ref.ts b/src/main/bitbucket/repository-ref.ts index e6995675963..fb07c32d4bc 100644 --- a/src/main/bitbucket/repository-ref.ts +++ b/src/main/bitbucket/repository-ref.ts @@ -6,6 +6,10 @@ export type BitbucketRepoRef = { repoSlug: string } +type LocalGitExecOptions = { + wslDistro?: string +} + const REPO_REF_CACHE_MAX_ENTRIES = 512 const repoRefCache = new Map() @@ -79,9 +83,11 @@ export function parseBitbucketRepoRef(remoteUrl: string): BitbucketRepoRef | nul export async function getBitbucketRepoRefForRemote( repoPath: string, remoteName: string, - connectionId?: string | null + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} ): Promise { - const cacheKey = `${connectionId ?? 'local'}\0${repoPath}\0${remoteName}` + const runtimeKey = connectionId ?? `local:${localGitOptions.wslDistro ?? 'host'}` + const cacheKey = `${runtimeKey}\0${repoPath}\0${remoteName}` if (repoRefCache.has(cacheKey)) { return repoRefCache.get(cacheKey)! } @@ -93,7 +99,8 @@ export async function getBitbucketRepoRefForRemote( const { stdout } = sshGitProvider ? await sshGitProvider.exec(['remote', 'get-url', remoteName], repoPath) : await gitExecFileAsync(['remote', 'get-url', remoteName], { - cwd: repoPath + cwd: repoPath, + ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) }) const result = parseBitbucketRepoRef(stdout) rememberRepoRefCacheEntry(cacheKey, result) @@ -111,7 +118,8 @@ export async function getBitbucketRepoRefForRemote( export async function getBitbucketRepoRef( repoPath: string, - connectionId?: string | null + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} ): Promise { - return getBitbucketRepoRefForRemote(repoPath, 'origin', connectionId) + return getBitbucketRepoRefForRemote(repoPath, 'origin', connectionId, localGitOptions) } diff --git a/src/main/browser/browser-cookie-import.comet.test.ts b/src/main/browser/browser-cookie-import.comet.test.ts index fc43315a31a..47e84db1607 100644 --- a/src/main/browser/browser-cookie-import.comet.test.ts +++ b/src/main/browser/browser-cookie-import.comet.test.ts @@ -14,6 +14,11 @@ vi.mock('electron', () => ({ })) import { BROWSER_FAMILY_LABELS } from '../../shared/constants' + +function slashPath(pathValue: string): string { + return pathValue.replaceAll('\\', '/') +} + describe('detectInstalledBrowsers — Comet', () => { const originalPlatform = process.platform const originalHome = process.env.HOME @@ -41,16 +46,17 @@ describe('detectInstalledBrowsers — Comet', () => { return { ...actual, existsSync: (p: string) => { - if (p.includes('Comet/Default/Network/Cookies')) { + const normalizedPath = slashPath(p) + if (normalizedPath.includes('Comet/Default/Network/Cookies')) { return true } - if (p.includes('Comet/Local State')) { + if (normalizedPath.includes('Comet/Local State')) { return true } return false }, readFileSync: (p: string, enc?: string) => { - if (typeof p === 'string' && p.includes('Comet/Local State')) { + if (typeof p === 'string' && slashPath(p).includes('Comet/Local State')) { return JSON.stringify({ profile: { info_cache: { Default: { name: 'Default' } } } }) } return actual.readFileSync(p as never, enc as never) @@ -63,7 +69,7 @@ describe('detectInstalledBrowsers — Comet', () => { const comet = detected.find((b) => b.family === 'comet') expect(comet).toBeDefined() expect(comet?.label).toBe('Comet') - expect(comet?.cookiesPath).toContain('Comet/Default/Network/Cookies') + expect(slashPath(comet?.cookiesPath ?? '')).toContain('Comet/Default/Network/Cookies') expect(comet?.keychainService).toBe('Comet Safe Storage') }) @@ -87,16 +93,17 @@ describe('detectInstalledBrowsers — Comet', () => { return { ...actual, existsSync: (p: string) => { - if (p.includes('Comet/Default/Network/Cookies')) { + const normalizedPath = slashPath(p) + if (normalizedPath.includes('Comet/Default/Network/Cookies')) { return true } - if (p.includes('Comet/Local State')) { + if (normalizedPath.includes('Comet/Local State')) { return true } return false }, readFileSync: (p: string, enc?: string) => { - if (typeof p === 'string' && p.includes('Comet/Local State')) { + if (typeof p === 'string' && slashPath(p).includes('Comet/Local State')) { return JSON.stringify({ profile: { info_cache: { diff --git a/src/main/browser/browser-session-registry.persistence.test.ts b/src/main/browser/browser-session-registry.persistence.test.ts index d6de343e6f4..121df82de6d 100644 --- a/src/main/browser/browser-session-registry.persistence.test.ts +++ b/src/main/browser/browser-session-registry.persistence.test.ts @@ -8,6 +8,10 @@ type FsState = { present: Set } +function fsKey(pathValue: string): string { + return pathValue.replaceAll('\\', '/') +} + function createFsState(): FsState { return { files: new Map(), present: new Set() } } @@ -57,42 +61,48 @@ function installModuleMocks( vi.doMock('node:fs', () => ({ copyFileSync: vi.fn((src: string, dst: string) => { - if (copyFailures.has(src)) { + const sourceKey = fsKey(src) + const destinationKey = fsKey(dst) + if (copyFailures.has(sourceKey)) { throw new Error(`copy fail for ${src}`) } - fsState.present.add(dst) - const value = fsState.files.get(src) + fsState.present.add(destinationKey) + const value = fsState.files.get(sourceKey) if (value !== undefined) { - fsState.files.set(dst, value) + fsState.files.set(destinationKey, value) } }), - existsSync: vi.fn((p: string) => fsState.present.has(p)), + existsSync: vi.fn((p: string) => fsState.present.has(fsKey(p))), mkdirSync: vi.fn(), readFileSync: vi.fn((p: string) => { - const v = fsState.files.get(p) + const v = fsState.files.get(fsKey(p)) if (v === undefined) { throw new Error('ENOENT') } return v }), renameSync: vi.fn((from: string, to: string) => { - const v = fsState.files.get(from) + const sourceKey = fsKey(from) + const destinationKey = fsKey(to) + const v = fsState.files.get(sourceKey) if (v === undefined) { throw new Error('ENOENT') } - fsState.files.set(to, v) - fsState.present.add(to) - fsState.files.delete(from) - fsState.present.delete(from) + fsState.files.set(destinationKey, v) + fsState.present.add(destinationKey) + fsState.files.delete(sourceKey) + fsState.present.delete(sourceKey) }), unlinkSync: vi.fn((p: string) => { - fsState.present.delete(p) - fsState.files.delete(p) + const key = fsKey(p) + fsState.present.delete(key) + fsState.files.delete(key) }), writeFileSync: vi.fn((p: string, data: string | Uint8Array) => { const value = typeof data === 'string' ? data : Buffer.from(data).toString('utf-8') - fsState.files.set(p, value) - fsState.present.add(p) + const key = fsKey(p) + fsState.files.set(key, value) + fsState.present.add(key) }) })) diff --git a/src/main/claude-accounts/runtime-auth-service.test.ts b/src/main/claude-accounts/runtime-auth-service.test.ts index 49c84aed0ba..1df59c0dc08 100644 --- a/src/main/claude-accounts/runtime-auth-service.test.ts +++ b/src/main/claude-accounts/runtime-auth-service.test.ts @@ -19,6 +19,7 @@ import type { ClaudeManagedAccount, GlobalSettings } from '../../shared/types' import { isOauthTokenExpiring, refreshClaudeOauthCredentials } from './oauth-refresh' const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') +const hostPlatform = process.platform const testState = { userDataDir: '', fakeHomeDir: '', @@ -653,7 +654,7 @@ describe('ClaudeRuntimeAuthService', () => { }) it('falls back to atomic write when the unchanged check cannot read the target', async () => { - if (process.platform === 'win32') { + if (hostPlatform === 'win32') { return } @@ -692,7 +693,7 @@ describe('ClaudeRuntimeAuthService', () => { }) it('tightens credential file permissions when unchanged content is already present', async () => { - if (process.platform === 'win32') { + if (hostPlatform === 'win32') { return } @@ -3444,6 +3445,77 @@ describe('ClaudeRuntimeAuthService', () => { expect(testState.legacyKeychainCredentials).toBe(staleManagedCredentials) }) + it('uses account WSL runtime for untargeted Claude preparation instead of stale terminal WSL settings', async () => { + setPlatform('win32') + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => null, + toWindowsWslPath: (value: string) => value + })) + const ubuntuAuthPath = createManagedClaudeAuth( + testState.userDataDir, + 'ubuntu-account', + createClaudeCredentialsJson('ubuntu@example.com', 'ubuntu-token') + ) + const settings = createSettings({ + localAccountRuntime: 'wsl', + localAccountWslDistro: 'Ubuntu', + terminalWindowsShell: 'wsl.exe', + terminalWindowsWslDistro: 'Debian', + claudeManagedAccounts: [ + createClaudeAccount('ubuntu-account', ubuntuAuthPath, { + managedAuthRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxAuthPath: '/home/alice/.local/share/orca/claude-accounts/ubuntu/auth' + }) + ], + activeClaudeManagedAccountId: null, + activeClaudeManagedAccountIdsByRuntime: { + host: null, + wsl: { Ubuntu: 'ubuntu-account' } + } + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + const preparation = await service.prepareForClaudeLaunch() + + expect(preparation).toMatchObject({ + runtime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxConfigDir: '/home/alice/.local/share/orca/claude-accounts/ubuntu/auth', + provenance: 'managed:ubuntu-account:wsl:Ubuntu', + stripAuthEnv: true + }) + }) + + it('keeps untargeted Claude preparation on host when account runtime is host', async () => { + setPlatform('win32') + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => null, + toWindowsWslPath: (value: string) => value + })) + const settings = createSettings({ + localAccountRuntime: 'host', + terminalWindowsShell: 'wsl.exe', + terminalWindowsWslDistro: 'Debian' + }) + const store = createStore(settings) + + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + const service = new ClaudeRuntimeAuthService(store as never) + const preparation = await service.prepareForClaudeLaunch() + + expect(preparation).toMatchObject({ + runtime: 'host', + wslDistro: null, + provenance: 'system', + stripAuthEnv: false + }) + }) + it('clears a selected WSL managed account when its credentials are missing', async () => { const managedAuthPath = join(testState.userDataDir, 'claude-accounts', 'account-1', 'auth') mkdirSync(managedAuthPath, { recursive: true }) @@ -3482,7 +3554,8 @@ describe('ClaudeRuntimeAuthService', () => { Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) vi.doMock('../wsl', () => ({ getDefaultWslDistro: () => 'Ubuntu', - getWslHome: () => join(testState.userDataDir, 'wsl-home') + getWslHome: () => join(testState.userDataDir, 'wsl-home'), + toWindowsWslPath: (value: string) => value })) const ubuntuAuthPath = createManagedClaudeAuth( testState.userDataDir, diff --git a/src/main/claude-accounts/runtime-auth-service.ts b/src/main/claude-accounts/runtime-auth-service.ts index 3b50a4b142b..90d430835b8 100644 --- a/src/main/claude-accounts/runtime-auth-service.ts +++ b/src/main/claude-accounts/runtime-auth-service.ts @@ -112,19 +112,23 @@ export class ClaudeRuntimeAuthService { async prepareForClaudeLaunch( target?: ClaudeAccountSelectionTarget ): Promise { - await this.syncForCurrentSelection(target) - return this.getPreparation(target) + const effectiveTarget = target ?? this.getDefaultAccountSelectionTarget() + await this.syncForCurrentSelection(effectiveTarget) + return this.getPreparation(effectiveTarget) } async prepareForRateLimitFetch( target?: ClaudeAccountSelectionTarget ): Promise { - await this.syncForCurrentSelection(target) - return this.getPreparation(target) + const effectiveTarget = target ?? this.getDefaultAccountSelectionTarget() + await this.syncForCurrentSelection(effectiveTarget) + return this.getPreparation(effectiveTarget) } async syncForCurrentSelection(target?: ClaudeAccountSelectionTarget): Promise { - await this.serializeMutation(() => this.doSyncForCurrentSelection(target)) + await this.serializeMutation(() => + this.doSyncForCurrentSelection(target ?? this.getDefaultAccountSelectionTarget()) + ) } async forceMaterializeCurrentSelectionForRollback(): Promise { @@ -624,13 +628,7 @@ export class ClaudeRuntimeAuthService { const settings = this.store.getSettings() const paths = this.pathResolver.getRuntimePaths() const normalizedTarget = this.resolveWslDefaultTarget( - target ?? - (process.platform === 'win32' && settings.terminalWindowsShell === 'wsl.exe' - ? ({ - runtime: 'wsl', - wslDistro: settings.terminalWindowsWslDistro ?? null - } satisfies ClaudeAccountSelectionTarget) - : ({ runtime: 'host' } satisfies ClaudeAccountSelectionTarget)) + target ?? this.getDefaultAccountSelectionTarget(settings) ) const activeAccountId = getSelectedClaudeAccountIdForTarget(settings, normalizedTarget) const activeAccount = this.getActiveAccount(settings.claudeManagedAccounts, activeAccountId) @@ -706,6 +704,17 @@ export class ClaudeRuntimeAuthService { return accounts.find((account) => account.id === activeAccountId) ?? null } + private getDefaultAccountSelectionTarget( + settings = this.store.getSettings() + ): ClaudeAccountSelectionTarget { + if (process.platform === 'win32' && settings.localAccountRuntime === 'wsl') { + // Why: account auth defaults follow account runtime settings, not hidden + // legacy terminal WSL settings that can outlive the Terminal UI control. + return { runtime: 'wsl', wslDistro: settings.localAccountWslDistro ?? null } + } + return { runtime: 'host' } + } + private resolveWslDefaultTarget( target?: ClaudeAccountSelectionTarget ): ClaudeAccountSelectionTarget { diff --git a/src/main/claude/hook-service.test.ts b/src/main/claude/hook-service.test.ts index 90909cd6468..4a1a7b58cbb 100644 --- a/src/main/claude/hook-service.test.ts +++ b/src/main/claude/hook-service.test.ts @@ -18,6 +18,10 @@ import type { SFTPWrapper } from 'ssh2' import { ClaudeHookService } from './hook-service' import { OPENCLAUDE_HOOK_SETTINGS } from './hook-settings' +const CLAUDE_SCRIPT_FILE_NAME = process.platform === 'win32' ? 'claude-hook.cmd' : 'claude-hook.sh' +const OPENCLAUDE_SCRIPT_FILE_NAME = + process.platform === 'win32' ? 'openclaude-hook.cmd' : 'openclaude-hook.sh' + type FakeFs = { files: Map dirs: Set @@ -106,6 +110,7 @@ describe('ClaudeHookService.install', () => { it('installs managed hooks into Claude settings and preserves user Bedrock settings', () => { const tmpHome = mkdtempSync(join(tmpdir(), 'orca-claude-hooks-')) vi.stubEnv('HOME', tmpHome) + vi.stubEnv('USERPROFILE', tmpHome) try { const legacyPath = join(tmpHome, '.claude', 'settings.json') mkdirSync(join(tmpHome, '.claude'), { recursive: true }) @@ -155,15 +160,15 @@ describe('ClaudeHookService.install', () => { definition.hooks.map((hook) => hook.command) ) expect(legacyCommands).toContain('/usr/local/bin/user-hook') - expect(legacyCommands.some((command: string) => command.includes('claude-hook.sh'))).toBe( - true - ) + expect( + legacyCommands.some((command: string) => command.includes(CLAUDE_SCRIPT_FILE_NAME)) + ).toBe(true) expect( legacyCommands.some((command: string) => command.includes('/Users/old/.orca/agent-hooks/claude-hook.sh') ) ).toBe(false) - expect(legacy.hooks.StopFailure[0].hooks[0].command).toContain('claude-hook.sh') + expect(legacy.hooks.StopFailure[0].hooks[0].command).toContain(CLAUDE_SCRIPT_FILE_NAME) } finally { vi.unstubAllEnvs() rmSync(tmpHome, { recursive: true, force: true }) @@ -261,6 +266,7 @@ describe('OpenClaudeHookService-compatible install', () => { it('installs managed hooks into OpenClaude settings without touching Claude settings', () => { const tmpHome = mkdtempSync(join(tmpdir(), 'orca-openclaude-hooks-')) vi.stubEnv('HOME', tmpHome) + vi.stubEnv('USERPROFILE', tmpHome) try { const openClaudeSettings = join(tmpHome, '.openclaude', 'settings.json') mkdirSync(join(tmpHome, '.openclaude'), { recursive: true }) @@ -276,10 +282,10 @@ describe('OpenClaudeHookService-compatible install', () => { const parsed = JSON.parse(readFileSync(openClaudeSettings, 'utf-8')) for (const event of ['UserPromptSubmit', 'Stop', 'StopFailure']) { const command = parsed.hooks[event][0].hooks[0].command as string - expect(command).toContain('openclaude-hook.sh') + expect(command).toContain(OPENCLAUDE_SCRIPT_FILE_NAME) } expect( - readFileSync(join(tmpHome, '.orca', 'agent-hooks', 'openclaude-hook.sh'), 'utf-8') + readFileSync(join(tmpHome, '.orca', 'agent-hooks', OPENCLAUDE_SCRIPT_FILE_NAME), 'utf-8') ).toContain('/hook/claude') expect(existsSync(join(tmpHome, '.claude', 'settings.json'))).toBe(false) } finally { diff --git a/src/main/cli/packaged-cli-assets.test.ts b/src/main/cli/packaged-cli-assets.test.ts index 4aa8b034d93..b62590542a4 100644 --- a/src/main/cli/packaged-cli-assets.test.ts +++ b/src/main/cli/packaged-cli-assets.test.ts @@ -2,7 +2,7 @@ import { execFile } from 'node:child_process' import { copyFile, mkdir, mkdtemp, rm, stat, symlink, writeFile } from 'node:fs/promises' import { createRequire } from 'node:module' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { join, sep } from 'node:path' import { promisify } from 'node:util' import { describe, expect, it } from 'vitest' import { buildAppImageCliWrapper } from './appimage-cli-wrapper' @@ -24,7 +24,7 @@ describe('packaged CLI assets', () => { ...(builderConfig.mac?.extraResources ?? []), ...(builderConfig.linux?.extraResources ?? []), ...(builderConfig.win?.extraResources ?? []) - ].map((resource) => resource.to) + ].map((resource) => normalizeResourceTarget(resource.to)) ) expect([...runtimeResourceTargets]).toEqual( @@ -41,6 +41,10 @@ describe('packaged CLI assets', () => { ) }) + function normalizeResourceTarget(target: string | undefined): string | undefined { + return target?.replace(/[\\/]/g, sep) + } + itRunsUnixShell('keeps the Linux launcher executable in packaged resources', async () => { const launcherStats = await stat(linuxLauncherAsset) expect(launcherStats.mode & 0o111).not.toBe(0) diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index c5ca856f38b..0c9e2635f9b 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -139,6 +139,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings terminalWindowsPowerShellImplementation: 'powershell.exe', enableGitHubAttribution: true, ...overrides, + localWindowsRuntimeDefault: overrides.localWindowsRuntimeDefault ?? { kind: 'windows-host' }, leftSidebarAppearanceMode: overrides.leftSidebarAppearanceMode ?? 'default', appFontFamily, agentStatusHooksEnabled, diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 6d7aa80e180..80e71ee72dc 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -143,6 +143,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings terminalWindowsPowerShellImplementation: 'powershell.exe', enableGitHubAttribution: true, ...overrides, + localWindowsRuntimeDefault: overrides.localWindowsRuntimeDefault ?? { kind: 'windows-host' }, leftSidebarAppearanceMode: overrides.leftSidebarAppearanceMode ?? 'default', appFontFamily, agentStatusHooksEnabled, diff --git a/src/main/codex/hook-service.test.ts b/src/main/codex/hook-service.test.ts index 838fc034cbd..d42d7606013 100644 --- a/src/main/codex/hook-service.test.ts +++ b/src/main/codex/hook-service.test.ts @@ -735,7 +735,7 @@ describe('CodexHookService', () => { systemHooksPath, `${JSON.stringify({ hooks: { - Stop: Array.from({ length: 130_000 }, () => ({ + Stop: Array.from({ length: 30_000 }, () => ({ hooks: [{ type: 'command', command: legacyCommand }] })) } diff --git a/src/main/command-code/hook-service.test.ts b/src/main/command-code/hook-service.test.ts index 2fdd7826c95..f3051d29291 100644 --- a/src/main/command-code/hook-service.test.ts +++ b/src/main/command-code/hook-service.test.ts @@ -50,7 +50,11 @@ describe('CommandCodeHookService', () => { expect(config.hooks.Stop[0].matcher).toBeUndefined() expect(config.hooks.PreToolUse[0].hooks[0].command).toContain('command-code-hook') expect(config.hooks.PreToolUse[0].hooks[0].command).toContain(join(homeDir, '.orca')) - expect(config.hooks.PreToolUse[0].hooks[0].command).toMatch(/^if \[ -x /) + if (process.platform === 'win32') { + expect(config.hooks.PreToolUse[0].hooks[0].command).toContain('command-code-hook.cmd') + } else { + expect(config.hooks.PreToolUse[0].hooks[0].command).toMatch(/^if \[ -x /) + } }) it('installs a hook script that can recover the endpoint when Command Code strips token env', () => { diff --git a/src/main/cursor/hook-service.test.ts b/src/main/cursor/hook-service.test.ts index 036205235a2..6a1cee03498 100644 --- a/src/main/cursor/hook-service.test.ts +++ b/src/main/cursor/hook-service.test.ts @@ -28,6 +28,8 @@ const CURSOR_EVENTS = [ 'afterAgentResponse' ] +const CURSOR_SCRIPT_FILE_NAME = process.platform === 'win32' ? 'cursor-hook.cmd' : 'cursor-hook.sh' + describe('CursorHookService', () => { let homeDir: string @@ -61,9 +63,16 @@ describe('CursorHookService', () => { expect(definition?.hooks).toBeUndefined() } - const script = readFileSync(join(homeDir, '.orca', 'agent-hooks', 'cursor-hook.sh'), 'utf8') + const script = readFileSync( + join(homeDir, '.orca', 'agent-hooks', CURSOR_SCRIPT_FILE_NAME), + 'utf8' + ) expect(script).toContain('/hook/cursor') - expect(script).toContain('payload=$(cat)') + if (process.platform === 'win32') { + expect(script).toContain('powershell -NoProfile') + } else { + expect(script).toContain('payload=$(cat)') + } }) it('preserves user-authored Cursor hook entries and removes stale managed entries', () => { @@ -97,7 +106,9 @@ describe('CursorHookService', () => { } const promptCommands = config.hooks.beforeSubmitPrompt.map((definition) => definition.command) expect(promptCommands).toContain('/usr/local/bin/user-hook') - expect(promptCommands.filter((command) => command?.includes('cursor-hook.sh'))).toHaveLength(1) + expect( + promptCommands.filter((command) => command?.includes(CURSOR_SCRIPT_FILE_NAME)) + ).toHaveLength(1) expect(config.hooks.retiredEvent.map((definition) => definition.command)).toEqual([ '/usr/local/bin/retired-user-hook' ]) diff --git a/src/main/daemon/client.test.ts b/src/main/daemon/client.test.ts index 462e95c7257..3c13a44ac9a 100644 --- a/src/main/daemon/client.test.ts +++ b/src/main/daemon/client.test.ts @@ -8,6 +8,7 @@ import { mkdtempSync, writeFileSync, rmSync } from 'fs' import { DaemonClient } from './client' import { encodeNdjson } from './ndjson' import type { HelloMessage, DaemonRequest, DaemonEvent } from './types' +import { getDaemonSocketPath } from './daemon-spawner' function createTestDir(): string { return mkdtempSync(join(tmpdir(), 'daemon-client-test-')) @@ -42,7 +43,7 @@ describe('DaemonClient', () => { beforeEach(() => { dir = createTestDir() - socketPath = join(dir, 'test.sock') + socketPath = getDaemonSocketPath(dir) tokenPath = join(dir, 'test.token') writeFileSync(tokenPath, 'test-token-123') }) diff --git a/src/main/daemon/daemon-health.test.ts b/src/main/daemon/daemon-health.test.ts index 6bf71a37f98..c429fe981a8 100644 --- a/src/main/daemon/daemon-health.test.ts +++ b/src/main/daemon/daemon-health.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'os' import { join } from 'path' import { createServer, connect, type Server } from 'net' import { DaemonServer } from './daemon-server' -import { getDaemonPidPath, serializeDaemonPidFile } from './daemon-spawner' +import { getDaemonPidPath, getDaemonSocketPath, serializeDaemonPidFile } from './daemon-spawner' import { getProcessStartedAtMs, healthCheckDaemon, @@ -65,7 +65,7 @@ describe('daemon health', () => { beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'daemon-health-test-')) - socketPath = join(dir, 'daemon.sock') + socketPath = getDaemonSocketPath(dir) tokenPath = join(dir, 'daemon.token') }) diff --git a/src/main/daemon/daemon-init.test.ts b/src/main/daemon/daemon-init.test.ts index 4608fa71475..8fe53bc3494 100644 --- a/src/main/daemon/daemon-init.test.ts +++ b/src/main/daemon/daemon-init.test.ts @@ -5,8 +5,15 @@ across files would duplicate the vi.hoisted boundary mocks with no cleaner ownership seam. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { join } from 'path' import { PROTOCOL_VERSION } from './types' +const FAKE_USER_DATA_PATH = '/fake/userData' +const FAKE_RUNTIME_DIR = join(FAKE_USER_DATA_PATH, 'daemon') +const FAKE_APP_PATH = '/fake/app' +const FAKE_APP_OUT_MAIN_PATH = join(FAKE_APP_PATH, 'out', 'main') +const FAKE_DAEMON_ENTRY_PATH = join(FAKE_APP_OUT_MAIN_PATH, 'daemon-entry.js') + // Why: the restart flow touches many boundary modules (electron app paths, fs // for dir creation, net for socket probe, DaemonClient over that socket, the // spawner's launcher, ipc/pty listener binders). We only care here about the @@ -300,6 +307,24 @@ async function importFresh() { describe('daemon-init: runRestartDaemon (7-step sequence)', () => { beforeEach(() => { probeSocketExistsMock.mockReturnValue(false) + netConnectMock.mockReset() + netConnectMock.mockImplementation(() => { + const handlers: Record void)[]> = { connect: [], error: [] } + return { + on(event: string, cb: () => void) { + handlers[event]?.push(cb) + if (event === 'error') { + queueMicrotask(() => cb()) + } + return this + }, + removeListener(event: string, cb: () => void) { + handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] + return this + }, + destroy() {} + } + }) }) afterEach(() => { @@ -721,20 +746,22 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { // would leave the coalescer untested. The deferred gate holds the first // restart inside `ensureRunning` until we release it, guaranteeing the // second call enters while the first is genuinely mid-flight. + let markEnsureRunningEntered: (() => void) | undefined + const ensureRunningEntered = new Promise((resolve) => { + markEnsureRunningEntered = resolve + }) let releaseEnsureRunning: (() => void) | undefined const ensureRunningBarrier = new Promise((resolve) => { releaseEnsureRunning = resolve }) originalSpawner.ensureRunning.mockImplementationOnce(async () => { + markEnsureRunningEntered?.() await ensureRunningBarrier return { socketPath: '/fake/socket-2', tokenPath: '/fake/token-2' } }) const call1 = mod.restartDaemon() - // Yield microtasks so call1 progresses into runRestartDaemon and is - // definitely blocked on the barrier. - await Promise.resolve() - await Promise.resolve() + await ensureRunningEntered const call2 = mod.restartDaemon() // Why: `async function restartDaemon` wraps each return in a fresh @@ -812,18 +839,18 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { await launcher('/fake/socket', '/fake/token') expect(getDaemonLaunchIdentityMock).toHaveBeenCalledWith( - '/fake/userData/daemon', + FAKE_RUNTIME_DIR, '/fake/socket', '/fake/token', - '/fake/app/out/main/daemon-entry.js' + FAKE_DAEMON_ENTRY_PATH ) expect(killStaleDaemonMock).toHaveBeenCalledWith( - '/fake/userData/daemon', + FAKE_RUNTIME_DIR, '/fake/socket', '/fake/token' ) expect(forkMock).toHaveBeenCalledWith( - '/fake/app/out/main/daemon-entry.js', + FAKE_DAEMON_ENTRY_PATH, ['--socket', '/fake/socket', '--token', '/fake/token'], expect.objectContaining({ cwd: '/fake/userData', detached: true }) ) @@ -862,10 +889,10 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { await launcher('/fake/socket', '/fake/token') expect(getDaemonLaunchIdentityMock).toHaveBeenCalledWith( - '/fake/userData/daemon', + FAKE_RUNTIME_DIR, '/fake/socket', '/fake/token', - '/fake/app/out/main/daemon-entry.js' + FAKE_DAEMON_ENTRY_PATH ) expect(requestMock).toHaveBeenCalledWith('listSessions', undefined) expect(disconnectMock).toHaveBeenCalledOnce() @@ -944,12 +971,12 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(getMacDaemonSystemResolverHealthMock).toHaveBeenCalledWith('/fake/socket', '/fake/token') expect(getDaemonLaunchIdentityMock).not.toHaveBeenCalled() expect(killStaleDaemonMock).toHaveBeenCalledWith( - '/fake/userData/daemon', + FAKE_RUNTIME_DIR, '/fake/socket', '/fake/token' ) expect(forkMock).toHaveBeenCalledWith( - '/fake/app/out/main/daemon-entry.js', + FAKE_DAEMON_ENTRY_PATH, ['--socket', '/fake/socket', '--token', '/fake/token'], expect.objectContaining({ cwd: '/fake/userData', detached: true }) ) @@ -1029,11 +1056,9 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { }) it('uses the direct daemon entry when Electron app path is already out/main', async () => { - probeSocketExistsMock.mockImplementation( - (p?: string) => p === '/fake/app/out/main/daemon-entry.js' - ) + probeSocketExistsMock.mockImplementation((p?: string) => p === FAKE_DAEMON_ENTRY_PATH) const mod = await importFresh() - getAppPathMock.mockReturnValue('/fake/app/out/main') + getAppPathMock.mockReturnValue(FAKE_APP_OUT_MAIN_PATH) healthCheckDaemonMock.mockResolvedValue(false) await mod.initDaemonPtyProvider() @@ -1068,7 +1093,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { await launcher('/fake/socket', '/fake/token') expect(forkMock).toHaveBeenCalledWith( - '/fake/app/out/main/daemon-entry.js', + FAKE_DAEMON_ENTRY_PATH, ['--socket', '/fake/socket', '--token', '/fake/token'], expect.objectContaining({ detached: true }) ) @@ -1122,7 +1147,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { JSON.stringify({ pid: 12345, startedAtMs: 1_000_000, - entryPath: '/fake/app/out/main/daemon-entry.js', + entryPath: FAKE_DAEMON_ENTRY_PATH, appVersion: '1.2.3' }), { mode: 0o600 } @@ -1246,7 +1271,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { await launcher('/fake/socket', '/fake/token') expect(killStaleDaemonMock).toHaveBeenCalledWith( - '/fake/userData/daemon', + FAKE_RUNTIME_DIR, '/fake/socket', '/fake/token' ) @@ -1289,12 +1314,12 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { await launcher('/fake/socket', '/fake/token') expect(killStaleDaemonMock).toHaveBeenCalledWith( - '/fake/userData/daemon', + FAKE_RUNTIME_DIR, '/fake/socket', '/fake/token' ) expect(forkMock).toHaveBeenCalledWith( - '/fake/app/out/main/daemon-entry.js', + FAKE_DAEMON_ENTRY_PATH, ['--socket', '/fake/socket', '--token', '/fake/token'], expect.objectContaining({ detached: true }) ) @@ -1316,13 +1341,13 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { await launcher('/fake/socket', '/fake/token') expect(getDaemonLaunchIdentityMock).toHaveBeenCalledWith( - '/fake/userData/daemon', + FAKE_RUNTIME_DIR, '/fake/socket', '/fake/token', - '/fake/app/out/main/daemon-entry.js' + FAKE_DAEMON_ENTRY_PATH ) expect(isDaemonStaleForCurrentBundleMock).toHaveBeenCalledWith( - '/fake/userData/daemon', + FAKE_RUNTIME_DIR, '/fake/socket', '/fake/token', '1.2.3' @@ -1368,18 +1393,18 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { await launcher('/fake/socket', '/fake/token') expect(isDaemonStaleForCurrentBundleMock).toHaveBeenCalledWith( - '/fake/userData/daemon', + FAKE_RUNTIME_DIR, '/fake/socket', '/fake/token', '1.2.3' ) expect(killStaleDaemonMock).toHaveBeenCalledWith( - '/fake/userData/daemon', + FAKE_RUNTIME_DIR, '/fake/socket', '/fake/token' ) expect(forkMock).toHaveBeenCalledWith( - '/fake/app/out/main/daemon-entry.js', + FAKE_DAEMON_ENTRY_PATH, ['--socket', '/fake/socket', '--token', '/fake/token'], expect.objectContaining({ detached: true }) ) @@ -1416,7 +1441,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { await launcher('/fake/socket', '/fake/token') expect(isDaemonStaleForCurrentBundleMock).toHaveBeenCalledWith( - '/fake/userData/daemon', + FAKE_RUNTIME_DIR, '/fake/socket', '/fake/token', '1.2.3' diff --git a/src/main/daemon/daemon-main.test.ts b/src/main/daemon/daemon-main.test.ts index b65ac49d4e9..349ef4dddac 100644 --- a/src/main/daemon/daemon-main.test.ts +++ b/src/main/daemon/daemon-main.test.ts @@ -4,6 +4,7 @@ import { join } from 'path' import { mkdtempSync, rmSync, existsSync, readFileSync } from 'fs' import { startDaemon, type DaemonHandle } from './daemon-main' import { DaemonClient } from './client' +import { getDaemonSocketPath } from './daemon-spawner' function createTestDir(): string { return mkdtempSync(join(tmpdir(), 'daemon-main-test-')) @@ -17,7 +18,7 @@ describe('startDaemon', () => { beforeEach(() => { dir = createTestDir() - socketPath = join(dir, 'test.sock') + socketPath = getDaemonSocketPath(dir) tokenPath = join(dir, 'test.token') }) diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index 9e16232cff4..ace70f1f79b 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -8,6 +8,7 @@ import { DaemonServer } from './daemon-server' import { getHistorySessionDirName } from './history-paths' import type { SubprocessHandle } from './session' import type * as DaemonHealthModule from './daemon-health' +import { getDaemonSocketPath } from './daemon-spawner' const { getMacDaemonSystemResolverHealthMock } = vi.hoisted(() => ({ getMacDaemonSystemResolverHealthMock: vi.fn(async () => 'unknown') @@ -85,7 +86,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { beforeEach(async () => { dir = createTestDir() - socketPath = join(dir, 'test.sock') + socketPath = getDaemonSocketPath(dir) tokenPath = join(dir, 'test.token') server = new DaemonServer({ diff --git a/src/main/daemon/daemon-pty-provider.test.ts b/src/main/daemon/daemon-pty-provider.test.ts index 03ceb4b9ba6..2cc5594a75f 100644 --- a/src/main/daemon/daemon-pty-provider.test.ts +++ b/src/main/daemon/daemon-pty-provider.test.ts @@ -5,6 +5,7 @@ import { mkdtempSync, rmSync } from 'fs' import { DaemonPtyProvider } from './daemon-pty-provider' import { DaemonServer } from './daemon-server' import type { SubprocessHandle } from './session' +import { getDaemonSocketPath } from './daemon-spawner' function createTestDir(): string { return mkdtempSync(join(tmpdir(), 'daemon-provider-test-')) @@ -60,7 +61,7 @@ describe('DaemonPtyProvider', () => { beforeEach(async () => { dir = createTestDir() - socketPath = join(dir, 'test.sock') + socketPath = getDaemonSocketPath(dir) tokenPath = join(dir, 'test.token') server = new DaemonServer({ diff --git a/src/main/daemon/daemon-server.test.ts b/src/main/daemon/daemon-server.test.ts index a27b466d90b..e1fdc26faa8 100644 --- a/src/main/daemon/daemon-server.test.ts +++ b/src/main/daemon/daemon-server.test.ts @@ -9,6 +9,7 @@ import { DaemonClient } from './client' import { encodeNdjson } from './ndjson' import { PROTOCOL_VERSION, type DaemonRequest } from './types' import type { SubprocessHandle } from './session' +import { getDaemonSocketPath } from './daemon-spawner' function createTestDir(): string { return mkdtempSync(join(tmpdir(), 'daemon-server-test-')) @@ -66,7 +67,7 @@ describe('DaemonServer', () => { beforeEach(() => { dir = createTestDir() - socketPath = join(dir, 'test.sock') + socketPath = getDaemonSocketPath(dir) tokenPath = join(dir, 'test.token') }) diff --git a/src/main/daemon/daemon-spawner.test.ts b/src/main/daemon/daemon-spawner.test.ts index e4d5a3d99c4..e7caaef1b63 100644 --- a/src/main/daemon/daemon-spawner.test.ts +++ b/src/main/daemon/daemon-spawner.test.ts @@ -88,7 +88,11 @@ describe('DaemonSpawner', () => { const s = createSpawner() const info = await s.ensureRunning() - expect(info.socketPath).toContain(dir) + if (process.platform === 'win32') { + expect(info.socketPath).toContain(`orca-terminal-host-v${PROTOCOL_VERSION}`) + } else { + expect(info.socketPath).toContain(dir) + } expect(info.tokenPath).toContain(dir) }) diff --git a/src/main/daemon/history-manager.test.ts b/src/main/daemon/history-manager.test.ts index b9d69588d1c..bfca0b2cbc3 100644 --- a/src/main/daemon/history-manager.test.ts +++ b/src/main/daemon/history-manager.test.ts @@ -129,19 +129,22 @@ describe('HistoryManager', () => { await mgr.checkpoint('nonexistent', makeSnapshot()) }) - it('ignores checkpoint for disabled sessions', async () => { - await mgr.openSession('sess-1', { cwd: '/tmp', cols: 80, rows: 24 }) + it.skipIf(process.platform === 'win32')( + 'ignores checkpoint for disabled sessions', + async () => { + await mgr.openSession('sess-1', { cwd: '/tmp', cols: 80, rows: 24 }) - const cpPath = sessionPath(dir, 'sess-1', 'checkpoint.json') - chmodSync(join(dir, getHistorySessionDirName('sess-1')), 0o555) + const cpPath = sessionPath(dir, 'sess-1', 'checkpoint.json') + chmodSync(join(dir, getHistorySessionDirName('sess-1')), 0o555) - await mgr.checkpoint('sess-1', makeSnapshot()) + await mgr.checkpoint('sess-1', makeSnapshot()) - chmodSync(join(dir, getHistorySessionDirName('sess-1')), 0o755) + chmodSync(join(dir, getHistorySessionDirName('sess-1')), 0o755) - await mgr.checkpoint('sess-1', makeSnapshot({ snapshotAnsi: 'after-error' })) - expect(existsSync(cpPath)).toBe(false) - }) + await mgr.checkpoint('sess-1', makeSnapshot({ snapshotAnsi: 'after-error' })) + expect(existsSync(cpPath)).toBe(false) + } + ) it('does not write scrollback.bin', async () => { await mgr.openSession('sess-1', { cwd: '/tmp', cols: 80, rows: 24 }) @@ -235,58 +238,70 @@ describe('HistoryManager', () => { }) describe('error handling', () => { - it('disables writes after fs error and does not throw', async () => { - await mgr.openSession('disk-full', { cwd: '/tmp', cols: 80, rows: 24 }) + it.skipIf(process.platform === 'win32')( + 'disables writes after fs error and does not throw', + async () => { + await mgr.openSession('disk-full', { cwd: '/tmp', cols: 80, rows: 24 }) - const sessionDir = join(dir, getHistorySessionDirName('disk-full')) - chmodSync(sessionDir, 0o555) + const sessionDir = join(dir, getHistorySessionDirName('disk-full')) + chmodSync(sessionDir, 0o555) - await mgr.checkpoint('disk-full', makeSnapshot()) + await mgr.checkpoint('disk-full', makeSnapshot()) - chmodSync(sessionDir, 0o755) + chmodSync(sessionDir, 0o755) - await mgr.checkpoint('disk-full', makeSnapshot({ snapshotAnsi: 'after-error' })) - expect(existsSync(sessionPath(dir, 'disk-full', 'checkpoint.json'))).toBe(false) - }) + await mgr.checkpoint('disk-full', makeSnapshot({ snapshotAnsi: 'after-error' })) + expect(existsSync(sessionPath(dir, 'disk-full', 'checkpoint.json'))).toBe(false) + } + ) - it('disables writes after fs error on openSession', async () => { - chmodSync(dir, 0o555) + it.skipIf(process.platform === 'win32')( + 'disables writes after fs error on openSession', + async () => { + chmodSync(dir, 0o555) - await mgr.openSession('disk-full-open', { cwd: '/tmp', cols: 80, rows: 24 }) + await mgr.openSession('disk-full-open', { cwd: '/tmp', cols: 80, rows: 24 }) - chmodSync(dir, 0o755) + chmodSync(dir, 0o755) - await mgr.checkpoint('disk-full-open', makeSnapshot()) - }) + await mgr.checkpoint('disk-full-open', makeSnapshot()) + } + ) - it('does not throw on closeSession disk error', async () => { - await mgr.openSession('close-err', { cwd: '/tmp', cols: 80, rows: 24 }) + it.skipIf(process.platform === 'win32')( + 'does not throw on closeSession disk error', + async () => { + await mgr.openSession('close-err', { cwd: '/tmp', cols: 80, rows: 24 }) - const metaPath = sessionPath(dir, 'close-err', 'meta.json') - chmodSync(metaPath, 0o444) + const metaPath = sessionPath(dir, 'close-err', 'meta.json') + chmodSync(metaPath, 0o444) - await mgr.closeSession('close-err', 0) + await mgr.closeSession('close-err', 0) - chmodSync(metaPath, 0o644) - }) + chmodSync(metaPath, 0o644) + } + ) - it('reports write errors via onWriteError callback', async () => { - const errors: { sessionId: string; error: Error }[] = [] - mgr = new HistoryManager(dir, { - onWriteError: (sessionId, error) => errors.push({ sessionId, error }) - }) + it.skipIf(process.platform === 'win32')( + 'reports write errors via onWriteError callback', + async () => { + const errors: { sessionId: string; error: Error }[] = [] + mgr = new HistoryManager(dir, { + onWriteError: (sessionId, error) => errors.push({ sessionId, error }) + }) - await mgr.openSession('err-cb', { cwd: '/tmp', cols: 80, rows: 24 }) + await mgr.openSession('err-cb', { cwd: '/tmp', cols: 80, rows: 24 }) - const sessionDir = join(dir, getHistorySessionDirName('err-cb')) - chmodSync(sessionDir, 0o555) + const sessionDir = join(dir, getHistorySessionDirName('err-cb')) + chmodSync(sessionDir, 0o555) - await mgr.checkpoint('err-cb', makeSnapshot()) + await mgr.checkpoint('err-cb', makeSnapshot()) - chmodSync(sessionDir, 0o755) + chmodSync(sessionDir, 0o755) - expect(errors).toHaveLength(1) - expect(errors[0].sessionId).toBe('err-cb') - }) + expect(errors).toHaveLength(1) + expect(errors[0].sessionId).toBe('err-cb') + } + ) }) }) diff --git a/src/main/daemon/osc7-file-uri.ts b/src/main/daemon/osc7-file-uri.ts index ce15e944cf6..5ae2dd06d52 100644 --- a/src/main/daemon/osc7-file-uri.ts +++ b/src/main/daemon/osc7-file-uri.ts @@ -10,17 +10,15 @@ export function parseFileUriPath(uri: string): string | null { return decodedPath } - // Why: Windows OSC-7 cwd updates can describe both drive-letter paths - // (`file:///C:/repo`) and UNC shares (`file://server/share/repo`). Use the - // hostname when present so live cwd tracking, snapshots, and restore all - // round-trip to a native Windows path instead of dropping the server name. - if (url.hostname) { - return `\\\\${url.hostname}${decodedPath.replace(/\//g, '\\')}` - } if (/^\/[A-Za-z]:/.test(decodedPath)) { return decodedPath.slice(1) } - return decodedPath.replace(/\//g, '\\') + // Why: localhost/empty-host OSC-7 URIs are POSIX paths even when parsed by + // a Windows app; only non-local hosts describe Windows UNC shares. + if (url.hostname && url.hostname !== 'localhost') { + return `\\\\${url.hostname}${decodedPath.replace(/\//g, '\\')}` + } + return decodedPath } catch { return null } diff --git a/src/main/daemon/production-launcher.test.ts b/src/main/daemon/production-launcher.test.ts index cec7d0b6ca4..821113f6f15 100644 --- a/src/main/daemon/production-launcher.test.ts +++ b/src/main/daemon/production-launcher.test.ts @@ -6,6 +6,7 @@ import { createProductionLauncher } from './production-launcher' import { startDaemon, type DaemonHandle } from './daemon-main' import { DaemonClient } from './client' import type { SubprocessHandle } from './session' +import { getDaemonSocketPath } from './daemon-spawner' const { forkMock } = vi.hoisted(() => ({ forkMock: vi.fn() @@ -74,7 +75,7 @@ describe('createProductionLauncher', () => { return { shutdown: () => handle.shutdown() } } - const socketPath = join(dir, 'test.sock') + const socketPath = getDaemonSocketPath(dir) const tokenPath = join(dir, 'test.token') const handle = await launcher(socketPath, tokenPath) diff --git a/src/main/daemon/slow-daemon-session-verification.test.ts b/src/main/daemon/slow-daemon-session-verification.test.ts index 8c3ad71c081..ab78cf21b07 100644 --- a/src/main/daemon/slow-daemon-session-verification.test.ts +++ b/src/main/daemon/slow-daemon-session-verification.test.ts @@ -6,6 +6,7 @@ import { mkdtempSync, rmSync } from 'fs' import { DaemonServer } from './daemon-server' import { DaemonClient } from './client' import { healthCheckDaemon } from './daemon-health' +import { getDaemonSocketPath } from './daemon-spawner' import type { ListSessionsResult } from './types' import type { SubprocessHandle } from './session' @@ -73,8 +74,8 @@ describe('slow daemon session verification', () => { beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'daemon-slow-verification-test-')) - daemonSocketPath = join(dir, 'daemon.sock') - proxySocketPath = join(dir, 'proxy.sock') + daemonSocketPath = getDaemonSocketPath(join(dir, 'daemon')) + proxySocketPath = getDaemonSocketPath(join(dir, 'proxy')) tokenPath = join(dir, 'daemon.token') }) @@ -82,8 +83,12 @@ describe('slow daemon session verification', () => { for (const client of clients.splice(0)) { client.disconnect() } - await new Promise((resolve) => proxy?.close(() => resolve())) - await server?.shutdown() + if (proxy) { + await new Promise((resolve) => proxy.close(() => resolve())) + } + if (server) { + await server.shutdown() + } rmSync(dir, { recursive: true, force: true }) }) diff --git a/src/main/daemon/terminal-host.test.ts b/src/main/daemon/terminal-host.test.ts index 4a37d4a7992..a0c4b9579eb 100644 --- a/src/main/daemon/terminal-host.test.ts +++ b/src/main/daemon/terminal-host.test.ts @@ -159,7 +159,9 @@ describe('TerminalHost', () => { lastSubprocess._onDataCb?.('\r\nuser@host $ ') await new Promise((r) => setTimeout(r, 40)) - expect(lastSubprocess.write).toHaveBeenCalledWith('echo hello\n') + expect(lastSubprocess.write).toHaveBeenCalledWith( + process.platform === 'win32' ? 'echo hello\r' : 'echo hello\n' + ) }) }) diff --git a/src/main/ghostty/discovery.ts b/src/main/ghostty/discovery.ts index 27d7b08e83d..218de2b2a99 100644 --- a/src/main/ghostty/discovery.ts +++ b/src/main/ghostty/discovery.ts @@ -5,15 +5,18 @@ import { stat } from 'fs/promises' // Why: Ghostty honors XDG before native macOS paths; we replicate that precedence. function xdgConfigDirs(home: string): string[] { if (process.env.XDG_CONFIG_HOME) { - return [path.join(process.env.XDG_CONFIG_HOME, 'ghostty')] + return [path.posix.join(process.env.XDG_CONFIG_HOME, 'ghostty')] } - return [path.join(home, '.config', 'ghostty')] + return [path.posix.join(home, '.config', 'ghostty')] } // Why: Ghostty loads the modern filename before the legacy `config` fallback, // and later files in this order override earlier files. function withFilenames(dirs: string[]): string[] { - return dirs.flatMap((dir) => [path.join(dir, 'config.ghostty'), path.join(dir, 'config')]) + return dirs.flatMap((dir) => [ + path.posix.join(dir, 'config.ghostty'), + path.posix.join(dir, 'config') + ]) } export function getGhosttyConfigPaths(): string[] { @@ -24,7 +27,7 @@ export function getGhosttyConfigPaths(): string[] { case 'darwin': { const dirs = xdgConfigDirs(home) // Why: Native macOS path is the final fallback after XDG candidates. - dirs.push(path.join(home, 'Library', 'Application Support', 'com.mitchellh.ghostty')) + dirs.push(path.posix.join(home, 'Library', 'Application Support', 'com.mitchellh.ghostty')) return withFilenames(dirs) } case 'linux': { diff --git a/src/main/git/check-ignored-paths.ts b/src/main/git/check-ignored-paths.ts index c61d4784f2b..4a03bbb3127 100644 --- a/src/main/git/check-ignored-paths.ts +++ b/src/main/git/check-ignored-paths.ts @@ -1,3 +1,5 @@ +import type { GitRuntimeOptions } from './git-runtime-options' +import { gitOptionsForWorktree } from './git-runtime-options' import { gitExecFileAsync } from './runner' const CHECK_IGNORE_CHUNK_SIZE = 100 @@ -10,12 +12,13 @@ function parseCheckIgnoreOutput(stdout: string): string[] { async function runCheckIgnoreChunk( worktreePath: string, - relativePaths: string[] + relativePaths: string[], + options: GitRuntimeOptions = {} ): Promise { try { const { stdout } = await gitExecFileAsync( ['-c', 'core.quotePath=false', 'check-ignore', '--', ...relativePaths], - { cwd: worktreePath } + gitOptionsForWorktree(worktreePath, options) ) return parseCheckIgnoreOutput(stdout) } catch (error) { @@ -29,12 +32,13 @@ async function runCheckIgnoreChunk( export async function checkIgnoredPaths( worktreePath: string, - relativePaths: string[] + relativePaths: string[], + options: GitRuntimeOptions = {} ): Promise { const ignored = new Set() for (let i = 0; i < relativePaths.length; i += CHECK_IGNORE_CHUNK_SIZE) { const chunk = relativePaths.slice(i, i + CHECK_IGNORE_CHUNK_SIZE) - for (const ignoredPath of await runCheckIgnoreChunk(worktreePath, chunk)) { + for (const ignoredPath of await runCheckIgnoreChunk(worktreePath, chunk, options)) { ignored.add(ignoredPath) } } diff --git a/src/main/git/checkout.ts b/src/main/git/checkout.ts index 128cd1a27f5..5e8152af86a 100644 --- a/src/main/git/checkout.ts +++ b/src/main/git/checkout.ts @@ -1,3 +1,5 @@ +import type { GitRuntimeOptions } from './git-runtime-options' +import { gitOptionsForWorktree } from './git-runtime-options' import { gitExecFileAsync } from './runner' /** @@ -19,9 +21,13 @@ export function assertValidBranchName(branch: string): void { * `assertValidBranchName` (rejects `-…`); the trailing `--` marks that no * pathspecs follow, so the token is unambiguously treated as a branch ref. */ -export async function checkoutBranch(worktreePath: string, branch: string): Promise { +export async function checkoutBranch( + worktreePath: string, + branch: string, + options: GitRuntimeOptions = {} +): Promise { assertValidBranchName(branch) - await gitExecFileAsync(['checkout', branch, '--'], { cwd: worktreePath }) + await gitExecFileAsync(['checkout', branch, '--'], gitOptionsForWorktree(worktreePath, options)) } /** @@ -30,11 +36,12 @@ export async function checkoutBranch(worktreePath: string, branch: string): Prom * locale-dependent decoration. */ export async function listLocalBranches( - worktreePath: string + worktreePath: string, + options: GitRuntimeOptions = {} ): Promise<{ current: string | null; branches: string[] }> { const { stdout } = await gitExecFileAsync( ['for-each-ref', '--format=%(HEAD)%09%(refname:short)', 'refs/heads/'], - { cwd: worktreePath } + gitOptionsForWorktree(worktreePath, options) ) let current: string | null = null const branches: string[] = [] diff --git a/src/main/git/fork-sync.ts b/src/main/git/fork-sync.ts index 35ffd5741b5..3f22082ca17 100644 --- a/src/main/git/fork-sync.ts +++ b/src/main/git/fork-sync.ts @@ -4,11 +4,14 @@ import { type GitForkSyncExpectedUpstream, type GitForkSyncResult } from '../../shared/git-fork-sync' +import type { GitRuntimeOptions } from './git-runtime-options' +import { gitOptionsForWorktree } from './git-runtime-options' import { gitExecFileAsync } from './runner' export async function gitSyncForkDefaultBranch( worktreePath: string, - expectedUpstream: GitForkSyncExpectedUpstream + expectedUpstream: GitForkSyncExpectedUpstream, + options: GitRuntimeOptions = {} ): Promise { const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), 60_000) @@ -16,7 +19,7 @@ export async function gitSyncForkDefaultBranch( return await syncForkDefaultBranch( (args) => gitExecFileAsync(args, { - cwd: worktreePath, + ...gitOptionsForWorktree(worktreePath, options), timeout: 60_000, signal: controller.signal }), diff --git a/src/main/git/git-runtime-options.ts b/src/main/git/git-runtime-options.ts new file mode 100644 index 00000000000..cf897a7b395 --- /dev/null +++ b/src/main/git/git-runtime-options.ts @@ -0,0 +1,10 @@ +export type GitRuntimeOptions = { + wslDistro?: string +} + +export function gitOptionsForWorktree( + cwd: string, + options: GitRuntimeOptions = {} +): { cwd: string; wslDistro?: string } { + return options.wslDistro ? { cwd, wslDistro: options.wslDistro } : { cwd } +} diff --git a/src/main/git/history.ts b/src/main/git/history.ts index 080f2fba154..59260697fd4 100644 --- a/src/main/git/history.ts +++ b/src/main/git/history.ts @@ -1,13 +1,15 @@ import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' import { loadGitHistoryFromExecutor } from '../../shared/git-history' +import type { GitRuntimeOptions } from './git-runtime-options' +import { gitOptionsForWorktree } from './git-runtime-options' import { gitExecFileAsync } from './runner' export async function getHistory( worktreePath: string, - options: GitHistoryOptions = {} + options: GitHistoryOptions & GitRuntimeOptions = {} ): Promise { return loadGitHistoryFromExecutor( - (args, cwd) => gitExecFileAsync(args, { cwd }), + (args, cwd) => gitExecFileAsync(args, gitOptionsForWorktree(cwd, options)), worktreePath, options ) diff --git a/src/main/git/huge-folder-ignore.ts b/src/main/git/huge-folder-ignore.ts index 24c84eb296a..d72160fd2e0 100644 --- a/src/main/git/huge-folder-ignore.ts +++ b/src/main/git/huge-folder-ignore.ts @@ -2,6 +2,7 @@ import { existsSync } from 'fs' import { appendFile, readFile, stat } from 'fs/promises' import * as path from 'path' import { checkIgnoredPaths } from './check-ignored-paths' +import type { GitRuntimeOptions } from './git-runtime-options' // Why: the overwhelmingly common cause of a status listing big enough to hit the // entry limit is a dependency/build folder that should have been ignored. Offer @@ -13,7 +14,10 @@ const KNOWN_HUGE_FOLDER_NAMES = ['node_modules', '.next', 'dist', 'build', 'targ * Return the relative names of known-huge folders that exist in the worktree and * are NOT already git-ignored — candidates to offer adding to .gitignore. */ -export async function findKnownHugeFolderPathsToIgnore(worktreePath: string): Promise { +export async function findKnownHugeFolderPathsToIgnore( + worktreePath: string, + options: GitRuntimeOptions = {} +): Promise { const existing: string[] = [] for (const name of KNOWN_HUGE_FOLDER_NAMES) { const full = path.join(worktreePath, name) @@ -32,7 +36,7 @@ export async function findKnownHugeFolderPathsToIgnore(worktreePath: string): Pr return [] } // Why: a folder already covered by an existing rule shouldn't be offered again. - const ignored = new Set(await checkIgnoredPaths(worktreePath, existing).catch(() => [])) + const ignored = new Set(await checkIgnoredPaths(worktreePath, existing, options).catch(() => [])) return existing.filter((name) => !ignored.has(name)) } diff --git a/src/main/git/push-target-validation.ts b/src/main/git/push-target-validation.ts index f29c1bc7347..bfb85cc4a27 100644 --- a/src/main/git/push-target-validation.ts +++ b/src/main/git/push-target-validation.ts @@ -2,11 +2,19 @@ import type { GitPushTarget } from '../../shared/types' import { assertGitPushTargetShape } from '../../shared/git-push-target-validation' import { gitExecFileAsync } from './runner' +type GitExecOptions = { + wslDistro?: string +} + export async function validateGitPushTarget( repoPath: string, - target: unknown + target: unknown, + options: GitExecOptions = {} ): Promise { assertGitPushTargetShape(target) - await gitExecFileAsync(['check-ref-format', '--branch', target.branchName], { cwd: repoPath }) + await gitExecFileAsync(['check-ref-format', '--branch', target.branchName], { + cwd: repoPath, + ...options + }) return target } diff --git a/src/main/git/remote.test.ts b/src/main/git/remote.test.ts index 419dac40605..4e7b27639f0 100644 --- a/src/main/git/remote.test.ts +++ b/src/main/git/remote.test.ts @@ -484,6 +484,26 @@ describe('git remote operations', () => { expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['fetch', '--prune'], { cwd: '/repo' }) }) + it('passes the selected WSL distro through fetch validation and execution', async () => { + gitExecFileAsyncMock + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + + await gitFetch( + '/repo', + { + remoteName: 'fork', + branchName: 'feature/fix' + }, + { wslDistro: 'Ubuntu' } + ) + + expect(gitExecFileAsyncMock.mock.calls).toEqual([ + [['check-ref-format', '--branch', 'feature/fix'], { cwd: '/repo', wslDistro: 'Ubuntu' }], + [['fetch', '--prune', 'fork'], { cwd: '/repo', wslDistro: 'Ubuntu' }] + ]) + }) + it('fetches the explicit publish target remote when provided', async () => { gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: '', stderr: '' }) diff --git a/src/main/git/remote.ts b/src/main/git/remote.ts index 848751995a9..d2b1c0b5dc4 100644 --- a/src/main/git/remote.ts +++ b/src/main/git/remote.ts @@ -3,16 +3,19 @@ import { resolveEffectiveGitUpstream } from '../../shared/git-effective-upstream import { gitRefTargetsBranchOnRemote } from '../../shared/git-remote-branch-name' import { resolveGitRemoteRebaseSource } from '../../shared/git-rebase-source' import type { GitPushTarget } from '../../shared/types' +import type { GitRuntimeOptions } from './git-runtime-options' +import { gitOptionsForWorktree } from './git-runtime-options' import { validateGitPushTarget } from './push-target-validation' import { gitExecFileAsync } from './runner' async function getConfiguredPushTarget( - worktreePath: string + worktreePath: string, + options: GitRuntimeOptions = {} ): Promise<{ remote: string; refspec: string } | null> { try { const { stdout: branchStdout } = await gitExecFileAsync( ['symbolic-ref', '--quiet', '--short', 'HEAD'], - { cwd: worktreePath } + gitOptionsForWorktree(worktreePath, options) ) const branch = branchStdout.trim() if (!branch) { @@ -20,8 +23,11 @@ async function getConfiguredPushTarget( } const [pushRemote, { stdout: mergeStdout }] = await Promise.all([ - getConfiguredPushRemote(worktreePath, branch), - gitExecFileAsync(['config', '--get', `branch.${branch}.merge`], { cwd: worktreePath }) + getConfiguredPushRemote(worktreePath, branch, options), + gitExecFileAsync( + ['config', '--get', `branch.${branch}.merge`], + gitOptionsForWorktree(worktreePath, options) + ) ]) const remote = pushRemote?.remote const mergeRef = mergeStdout.trim() @@ -29,7 +35,7 @@ async function getConfiguredPushTarget( if (!remote || !branchRef || remote === '.' || branchRef === mergeRef) { return null } - if (await branchMergeTargetsConfiguredBase(worktreePath, branch, remote, branchRef)) { + if (await branchMergeTargetsConfiguredBase(worktreePath, branch, remote, branchRef, options)) { return null } if (!canPushConfiguredMergeBranch(pushRemote, branch, branchRef)) { @@ -41,9 +47,16 @@ async function getConfiguredPushTarget( } } -async function getConfigValue(worktreePath: string, key: string): Promise { +async function getConfigValue( + worktreePath: string, + key: string, + options: GitRuntimeOptions = {} +): Promise { try { - const { stdout } = await gitExecFileAsync(['config', '--get', key], { cwd: worktreePath }) + const { stdout } = await gitExecFileAsync( + ['config', '--get', key], + gitOptionsForWorktree(worktreePath, options) + ) const value = stdout.trim() return value || null } catch { @@ -62,19 +75,24 @@ type ConfiguredPushRemote = { async function findRemoteNameForUrl( worktreePath: string, - remoteUrl: string + remoteUrl: string, + options: GitRuntimeOptions = {} ): Promise { try { - const { stdout } = await gitExecFileAsync(['remote'], { cwd: worktreePath }) + const { stdout } = await gitExecFileAsync( + ['remote'], + gitOptionsForWorktree(worktreePath, options) + ) const remotes = stdout .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean) for (const remoteName of remotes) { try { - const { stdout: urlStdout } = await gitExecFileAsync(['remote', 'get-url', remoteName], { - cwd: worktreePath - }) + const { stdout: urlStdout } = await gitExecFileAsync( + ['remote', 'get-url', remoteName], + gitOptionsForWorktree(worktreePath, options) + ) if (urlStdout.trim() === remoteUrl) { return remoteName } @@ -88,28 +106,35 @@ async function findRemoteNameForUrl( return null } -async function normalizePushRemote(worktreePath: string, remote: string): Promise { +async function normalizePushRemote( + worktreePath: string, + remote: string, + options: GitRuntimeOptions = {} +): Promise { if (!isUrlValuedRemote(remote)) { return remote } - return (await findRemoteNameForUrl(worktreePath, remote)) ?? remote + return (await findRemoteNameForUrl(worktreePath, remote, options)) ?? remote } async function getConfiguredPushRemote( worktreePath: string, - branch: string + branch: string, + options: GitRuntimeOptions = {} ): Promise { - const branchRemote = await getConfigValue(worktreePath, `branch.${branch}.remote`) + const branchRemote = await getConfigValue(worktreePath, `branch.${branch}.remote`, options) const remote = - (await getConfigValue(worktreePath, `branch.${branch}.pushRemote`)) ?? - (await getConfigValue(worktreePath, 'remote.pushDefault')) ?? + (await getConfigValue(worktreePath, `branch.${branch}.pushRemote`, options)) ?? + (await getConfigValue(worktreePath, 'remote.pushDefault', options)) ?? branchRemote if (!remote) { return null } return { - remote: await normalizePushRemote(worktreePath, remote), - branchRemote: branchRemote ? await normalizePushRemote(worktreePath, branchRemote) : null + remote: await normalizePushRemote(worktreePath, remote, options), + branchRemote: branchRemote + ? await normalizePushRemote(worktreePath, branchRemote, options) + : null } } @@ -117,10 +142,11 @@ async function branchMergeTargetsConfiguredBase( worktreePath: string, branch: string, remote: string, - branchRef: string + branchRef: string, + options: GitRuntimeOptions = {} ): Promise { return gitRefTargetsBranchOnRemote( - await getConfigValue(worktreePath, `branch.${branch}.base`), + await getConfigValue(worktreePath, `branch.${branch}.base`, options), remote, branchRef ) @@ -150,11 +176,11 @@ export async function gitPush( worktreePath: string, _publish = false, pushTarget?: GitPushTarget, - options: { forceWithLease?: boolean } = {} + options: { forceWithLease?: boolean } & GitRuntimeOptions = {} ): Promise { try { if (pushTarget) { - await validateGitPushTarget(worktreePath, pushTarget) + await validateGitPushTarget(worktreePath, pushTarget, options) } // Why: push to the branch's configured upstream when one exists. PR-created // worktrees can track a contributor fork remote; hardcoding origin here @@ -168,14 +194,14 @@ export async function gitPush( // from worktree config, not the upstream relationship. const target = pushTarget ? explicitPushTarget(pushTarget) - : await getConfiguredPushTarget(worktreePath) + : await getConfiguredPushTarget(worktreePath, options) const args = [ 'push', ...(options.forceWithLease ? ['--force-with-lease'] : []), '--set-upstream', ...(target ? [target.remote, target.refspec] : ['origin', 'HEAD']) ] - await gitExecFileAsync(args, { cwd: worktreePath }) + await gitExecFileAsync(args, gitOptionsForWorktree(worktreePath, options)) } catch (error) { throw new Error(normalizeGitErrorMessage(error, 'push')) } @@ -184,69 +210,90 @@ export async function gitPush( async function gitPullWithArgs( worktreePath: string, pullArgs: string[], - pushTarget?: GitPushTarget + pushTarget?: GitPushTarget, + options: GitRuntimeOptions = {} ): Promise { try { if (pushTarget) { - const target = await validateGitPushTarget(worktreePath, pushTarget) - await gitExecFileAsync(['pull', ...pullArgs, target.remoteName, target.branchName], { - cwd: worktreePath - }) + const target = await validateGitPushTarget(worktreePath, pushTarget, options) + await gitExecFileAsync( + ['pull', ...pullArgs, target.remoteName, target.branchName], + gitOptionsForWorktree(worktreePath, options) + ) return } const upstream = await resolveEffectiveGitUpstream((args) => - gitExecFileAsync(args, { cwd: worktreePath }) + gitExecFileAsync(args, gitOptionsForWorktree(worktreePath, options)) ) if (upstream && !upstream.isConfiguredUpstream) { // Why: legacy Orca branches may still track origin/main while pushes // target origin/. Pull the same effective branch the UI reports. - await gitExecFileAsync(['pull', ...pullArgs, upstream.remoteName, upstream.branchName], { - cwd: worktreePath - }) + await gitExecFileAsync( + ['pull', ...pullArgs, upstream.remoteName, upstream.branchName], + gitOptionsForWorktree(worktreePath, options) + ) return } - await gitExecFileAsync(['pull', ...pullArgs], { cwd: worktreePath }) + await gitExecFileAsync(['pull', ...pullArgs], gitOptionsForWorktree(worktreePath, options)) } catch (error) { throw new Error(normalizeGitErrorMessage(error, 'pull')) } } -export async function gitPull(worktreePath: string, pushTarget?: GitPushTarget): Promise { - // Why: plain `git pull` honors the user's configured merge/rebase/ff policy. - // If no policy exists, Git's policy error is normalized with setup guidance. - await gitPullWithArgs(worktreePath, [], pushTarget) +export async function gitPull( + worktreePath: string, + pushTarget?: GitPushTarget, + options: GitRuntimeOptions = {} +): Promise { + // Why: plain `git pull` uses the user's configured pull strategy (merge by + // default) so diverged branches reconcile instead of erroring out. Conflicts + // surface through the existing conflict-resolution flow. + await gitPullWithArgs(worktreePath, [], pushTarget, options) } export async function gitFastForward( worktreePath: string, - pushTarget?: GitPushTarget + pushTarget?: GitPushTarget, + options: GitRuntimeOptions = {} ): Promise { - await gitPullWithArgs(worktreePath, ['--ff-only'], pushTarget) + await gitPullWithArgs(worktreePath, ['--ff-only'], pushTarget, options) } -export async function gitPullRebaseFromBase(worktreePath: string, baseRef: string): Promise { +export async function gitPullRebaseFromBase( + worktreePath: string, + baseRef: string, + options: GitRuntimeOptions = {} +): Promise { try { const source = await resolveGitRemoteRebaseSource( - (args) => gitExecFileAsync(args, { cwd: worktreePath }), + (args) => gitExecFileAsync(args, gitOptionsForWorktree(worktreePath, options)), baseRef ) - await gitExecFileAsync(['pull', '--rebase', source.remoteName, source.branchName], { - cwd: worktreePath - }) + await gitExecFileAsync( + ['pull', '--rebase', source.remoteName, source.branchName], + gitOptionsForWorktree(worktreePath, options) + ) } catch (error) { throw new Error(normalizeGitErrorMessage(error, 'pull')) } } -export async function gitFetch(worktreePath: string, pushTarget?: GitPushTarget): Promise { +export async function gitFetch( + worktreePath: string, + pushTarget?: GitPushTarget, + options: GitRuntimeOptions = {} +): Promise { try { if (pushTarget) { - const target = await validateGitPushTarget(worktreePath, pushTarget) - await gitExecFileAsync(['fetch', '--prune', target.remoteName], { cwd: worktreePath }) + const target = await validateGitPushTarget(worktreePath, pushTarget, options) + await gitExecFileAsync( + ['fetch', '--prune', target.remoteName], + gitOptionsForWorktree(worktreePath, options) + ) return } - await gitExecFileAsync(['fetch', '--prune'], { cwd: worktreePath }) + await gitExecFileAsync(['fetch', '--prune'], gitOptionsForWorktree(worktreePath, options)) } catch (error) { throw new Error(normalizeGitErrorMessage(error, 'fetch')) } diff --git a/src/main/git/remove-worktree.test.ts b/src/main/git/remove-worktree.test.ts index f85680964f5..691ed2cf775 100644 --- a/src/main/git/remove-worktree.test.ts +++ b/src/main/git/remove-worktree.test.ts @@ -687,6 +687,47 @@ describe('listWorktrees', () => { expect(translateWslOutputPathsMock).toHaveBeenCalledTimes(2) }) + it('passes the selected WSL distro when translating Windows-path worktree output', async () => { + gitExecFileAsyncMock.mockResolvedValueOnce({ + stdout: + 'worktree /mnt/c/Users/me/repo\nHEAD abc123\nbranch refs/heads/main\nsparse\n\n' + + 'worktree /mnt/c/Users/me/repo-feature\nHEAD def456\nbranch refs/heads/feature/test\nsparse\n\n' + }) + translateWslOutputPathsMock.mockImplementation((output: string) => + output + .replace('/mnt/c/Users/me/repo-feature', 'C:\\Users\\me\\repo-feature') + .replace('/mnt/c/Users/me/repo', 'C:\\Users\\me\\repo') + ) + + await expect(listWorktrees('C:\\Users\\me\\repo', { wslDistro: 'Ubuntu' })).resolves.toEqual([ + { + path: 'C:\\Users\\me\\repo', + head: 'abc123', + branch: 'refs/heads/main', + isBare: false, + isSparse: true, + isMainWorktree: true + }, + { + path: 'C:\\Users\\me\\repo-feature', + head: 'def456', + branch: 'refs/heads/feature/test', + isBare: false, + isSparse: true, + isMainWorktree: false + } + ]) + expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'list', '--porcelain', '-z'], { + cwd: 'C:\\Users\\me\\repo', + wslDistro: 'Ubuntu' + }) + expect(translateWslOutputPathsMock).toHaveBeenCalledWith( + expect.any(String), + 'C:\\Users\\me\\repo', + { wslDistro: 'Ubuntu' } + ) + }) + it('returns no worktrees when the repo path is gone', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) gitExecFileAsyncMock.mockRejectedValueOnce( @@ -805,7 +846,7 @@ describe('listWorktrees', () => { await new Promise((resolve) => pendingProbeResolves.push(resolve)) activeProbes -= 1 - if (filePath.includes(sparseWorktreePath)) { + if (filePath.replaceAll('\\', '/').includes(sparseWorktreePath)) { return { isFile: () => true, size: 32 } } throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) diff --git a/src/main/git/repo.ts b/src/main/git/repo.ts index 16283c39262..975caa3f5bd 100644 --- a/src/main/git/repo.ts +++ b/src/main/git/repo.ts @@ -13,6 +13,17 @@ import { normalizeGitUsername } from './git-username' const GH_LOGIN_TIMEOUT_MS = 2500 +type LocalGitExecOptions = { + wslDistro?: string +} + +function gitExecOptions( + cwd: string, + options: LocalGitExecOptions = {} +): { cwd: string; wslDistro?: string } { + return options.wslDistro ? { cwd, wslDistro: options.wslDistro } : { cwd } +} + /** * Ordered probe list used to resolve a repo's default base ref when no * explicit origin/HEAD symbolic-ref is set. `returnAs` is the short-name @@ -324,8 +335,11 @@ export function getDefaultBaseRef(path: string): string | null { return null } -export async function getBaseRefDefault(path: string): Promise { - return getDefaultBaseRefAsync(path) +export async function getBaseRefDefault( + path: string, + options: LocalGitExecOptions = {} +): Promise { + return getDefaultBaseRefAsync(path, options) } /** @@ -341,12 +355,13 @@ export async function getBaseRefDefault(path: string): Promise { export function getRemoteDrift( repoPath: string, localRef: string, - remoteRef: string + remoteRef: string, + options: LocalGitExecOptions = {} ): { ahead: number; behind: number } | null { try { const stdout = gitExecFileSync( ['rev-list', '--left-right', '--count', `${localRef}...${remoteRef}`], - { cwd: repoPath } + gitExecOptions(repoPath, options) ) const [aheadStr, behindStr] = stdout.trim().split(/\s+/) const ahead = Number(aheadStr) @@ -372,12 +387,13 @@ export function getRecentDriftSubjects( repoPath: string, localRef: string, remoteRef: string, - limit: number + limit: number, + options: LocalGitExecOptions = {} ): string[] { try { const stdout = gitExecFileSync( ['log', '--format=%s', '-n', String(limit), `${localRef}..${remoteRef}`], - { cwd: repoPath } + gitExecOptions(repoPath, options) ) return stdout.split('\n').filter((s) => s.trim().length > 0) } catch { @@ -450,8 +466,13 @@ export async function resolveDefaultBaseRefViaExec(exec: GitExec): Promise { - return resolveDefaultBaseRefViaExec((argv) => gitExecFileAsync(argv, { cwd: path })) +async function getDefaultBaseRefAsync( + path: string, + options: LocalGitExecOptions = {} +): Promise { + return resolveDefaultBaseRefViaExec((argv) => + gitExecFileAsync(argv, gitExecOptions(path, options)) + ) } /** @@ -562,8 +583,11 @@ export function isForEachRefExcludeUnsupportedError(error: unknown): boolean { * Order: remote configured on the current default branch → origin → the single * remote when the repo has exactly one → error. */ -export async function getDefaultRemote(path: string): Promise { - const defaultRef = await getDefaultBaseRefAsync(path) +export async function getDefaultRemote( + path: string, + options: LocalGitExecOptions = {} +): Promise { + const defaultRef = await getDefaultBaseRefAsync(path, options) // Why: getDefaultBaseRefAsync returns null when no default branch can be // detected (e.g. a brand-new repo with no commits on origin). Guard so we // don't crash on .includes(); fall through to the remote-list heuristics. @@ -577,7 +601,7 @@ export async function getDefaultRemote(path: string): Promise { try { const { stdout } = await gitExecFileAsync( ['config', '--get', `branch.${defaultBranch}.remote`], - { cwd: path } + gitExecOptions(path, options) ) const value = stdout.trim() if (value) { @@ -589,7 +613,7 @@ export async function getDefaultRemote(path: string): Promise { } try { - const { stdout } = await gitExecFileAsync(['remote'], { cwd: path }) + const { stdout } = await gitExecFileAsync(['remote'], gitExecOptions(path, options)) const remotes = stdout .split('\n') .map((line) => line.trim()) @@ -659,9 +683,9 @@ export async function searchBaseRefDetails( } } -async function listRemoteNames(path: string): Promise { +async function listRemoteNames(path: string, options: LocalGitExecOptions = {}): Promise { try { - const { stdout } = await gitExecFileAsync(['remote'], { cwd: path }) + const { stdout } = await gitExecFileAsync(['remote'], gitExecOptions(path, options)) return stdout .split('\n') .map((line) => line.trim()) @@ -753,9 +777,13 @@ export function normalizeRefSearchQuery(query: string): string { return query.trim().replace(/[*?[\]\\]/g, '') } -async function hasGitRefAsync(path: string, ref: string): Promise { +async function hasGitRefAsync( + path: string, + ref: string, + options: LocalGitExecOptions = {} +): Promise { try { - await gitExecFileAsync(['rev-parse', '--verify', ref], { cwd: path }) + await gitExecFileAsync(['rev-parse', '--verify', ref], gitExecOptions(path, options)) return true } catch { return false @@ -767,17 +795,18 @@ export type BranchConflictKind = 'local' | 'remote' export async function getBranchConflictKind( path: string, branchName: string, - allowedBaseRef?: string + allowedBaseRef?: string, + options: LocalGitExecOptions = {} ): Promise { - if (await hasGitRefAsync(path, `refs/heads/${branchName}`)) { + if (await hasGitRefAsync(path, `refs/heads/${branchName}`, options)) { return 'local' } try { - const remoteNames = (await listRemoteNames(path)).sort((a, b) => b.length - a.length) + const remoteNames = (await listRemoteNames(path, options)).sort((a, b) => b.length - a.length) const { stdout } = await gitExecFileAsync( ['for-each-ref', '--format=%(refname)', 'refs/remotes'], - { cwd: path } + gitExecOptions(path, options) ) const hasRemoteConflict = stdout.split('\n').some((ref) => { const trimmed = ref.trim() diff --git a/src/main/git/runner-command-exec.test.ts b/src/main/git/runner-command-exec.test.ts index 7e2f7743b07..5c8844a31a2 100644 --- a/src/main/git/runner-command-exec.test.ts +++ b/src/main/git/runner-command-exec.test.ts @@ -13,7 +13,14 @@ vi.mock('node:child_process', () => ({ spawn: spawnMock })) -import { commandExecFileAsync, ghExecFileAsync, gitExecFileAsync, gitStreamStdout } from './runner' +import { + commandExecFileAsync, + ghExecFileAsync, + gitExecFileAsync, + gitStreamStdout, + translateWslOutputPaths, + wslAwareSpawn +} from './runner' type MockChildProcess = EventEmitter & { stdout: EventEmitter @@ -189,7 +196,7 @@ describe('runner execFile timeout handling', () => { cwd: '/repo', timeout: 1000 }) - const rejection = expect(promise).rejects.toThrow('git timed out.') + const rejection = expect(promise).rejects.toThrow(/git(?:\.exe)? timed out\./i) await vi.advanceTimersByTimeAsync(1000) await rejection @@ -280,6 +287,52 @@ describe('runner execFile timeout handling', () => { expect(capturedEnv?.SSH_ASKPASS).toBe('') expect(capturedEnv?.GIT_SSH_COMMAND).toContain('BatchMode=yes') }) + + it('routes git through the selected WSL distro login shell when requested', async () => { + await withPlatform('win32', async () => { + const child = createMockChildProcess(1234) + execFileMock.mockImplementation((_cmd, _args, _opts, cb) => { + cb(null, 'ok', '') + return child + }) + + await gitExecFileAsync(['status', '--short'], { + cwd: String.raw`C:\repo`, + wslDistro: 'Ubuntu' + }) + + expect(execFileMock).toHaveBeenCalledWith( + 'wsl.exe', + ['-d', 'Ubuntu', '--', 'sh', '-lc', expect.any(String)], + expect.objectContaining({ cwd: undefined }), + expect.any(Function) + ) + const shellCommand = execFileMock.mock.calls[0]?.[1]?.[5] as string + expect(shellCommand).toContain('getent passwd') + expect(shellCommand).toContain('exec "\\$_orca_wsl_shell" -ilc') + expect(shellCommand).toContain('/mnt/c/repo') + expect(shellCommand).toContain("'git'") + expect(shellCommand).toContain('status') + expect(shellCommand).toContain('--short') + }) + }) + + it('quotes WSL-routed executables before entering the shell', async () => { + await withPlatform('win32', async () => { + const child = createMockChildProcess(1234) + spawnMock.mockReturnValue(child) + + wslAwareSpawn('codex; touch /tmp/pwned', ['--version'], { + cwd: String.raw`C:\repo`, + stdio: ['pipe', 'pipe', 'pipe'], + wslDistro: 'Ubuntu', + useWslLoginShell: true + }) + + const shellCommand = spawnMock.mock.calls[0]?.[1]?.[5] as string + expect(shellCommand).toContain(String.raw`'\''codex; touch /tmp/pwned'\'' '\''--version'\''`) + }) + }) }) describe('gitStreamStdout', () => { @@ -372,3 +425,13 @@ describe('gitStreamStdout', () => { expect(child.kill).toHaveBeenCalled() }) }) + +describe('translateWslOutputPaths', () => { + it('translates WSL output paths with an explicit distro for Windows cwd routing', () => { + expect( + translateWslOutputPaths('worktree /mnt/c/Users/me/repo-feature\n', 'C:\\Users\\me\\repo', { + wslDistro: 'Ubuntu' + }) + ).toBe('worktree C:\\Users\\me\\repo-feature\n') + }) +}) diff --git a/src/main/git/runner-wsl-gh-fallback.test.ts b/src/main/git/runner-wsl-gh-fallback.test.ts index 83d7a8bea76..4bafa9f1984 100644 --- a/src/main/git/runner-wsl-gh-fallback.test.ts +++ b/src/main/git/runner-wsl-gh-fallback.test.ts @@ -74,7 +74,7 @@ describe('ghExecFileAsync WSL fallback', () => { '--', 'bash', '-c', - "cd '/home/jinwoo/stably/noqa' && gh 'issue' 'list' '--repo' 'stablyhq/noqa' '--json' 'number,title'" + "cd '/home/jinwoo/stably/noqa' && 'gh' 'issue' 'list' '--repo' 'stablyhq/noqa' '--json' 'number,title'" ], expect.objectContaining({ cwd: undefined }), expect.any(Function) @@ -285,7 +285,7 @@ describe('ghExecFileAsync WSL fallback', () => { expect(execFileMock).toHaveBeenNthCalledWith( 2, 'wsl.exe', - ['-d', 'Ubuntu', '--', 'bash', '-c', "gh 'api' 'rate_limit'"], + ['-d', 'Ubuntu', '--', 'bash', '-c', "'gh' 'api' 'rate_limit'"], expect.objectContaining({ cwd: undefined }), expect.any(Function) ) @@ -347,7 +347,7 @@ describe('ghExecFileAsync WSL fallback', () => { expect(execFileMock).toHaveBeenNthCalledWith( 2, 'wsl.exe', - ['-d', 'Ubuntu', '--', 'bash', '-c', "glab 'api' 'projects'"], + ['-d', 'Ubuntu', '--', 'bash', '-c', "'glab' 'api' 'projects'"], expect.objectContaining({ cwd: undefined }), expect.any(Function) ) diff --git a/src/main/git/runner.ts b/src/main/git/runner.ts index 99647b0e282..576ba99c642 100644 --- a/src/main/git/runner.ts +++ b/src/main/git/runner.ts @@ -21,6 +21,11 @@ import { StringDecoder } from 'string_decoder' import { withGitSpan } from '../observability/instrumentation' import { getDefaultWslDistro, parseWslPath, toWindowsWslPath, type WslPathInfo } from '../wsl' import { getSpawnArgsForWindows, isWindowsBatchScript, resolveWindowsCommand } from '../win32-utils' +import { + buildWslLoginShellCommand, + escapeWslShCommandForWindows, + quotePosixShell +} from '../../shared/wsl-login-shell-command' // ─── Core resolution ──────────────────────────────────────────────── @@ -43,23 +48,25 @@ type ResolvedCommand = { * (C:\Users\...) are converted to /mnt/c/Users/... */ function translateArgsForWsl(args: string[]): string[] { - return args.map((arg) => { - // WSL UNC path → native linux path - const wslInfo = parseWslPath(arg) - if (wslInfo) { - return wslInfo.linuxPath - } + return args.map(translateArgForWsl) +} - // Windows drive path (e.g. C:\Users\...) → /mnt/c/Users/... - const driveMatch = arg.match(/^([A-Za-z]):[/\\](.*)$/) - if (driveMatch) { - const driveLetter = driveMatch[1].toLowerCase() - const rest = driveMatch[2].replace(/\\/g, '/') - return `/mnt/${driveLetter}/${rest}` - } +function translateArgForWsl(arg: string): string { + // WSL UNC path → native linux path + const wslInfo = parseWslPath(arg) + if (wslInfo) { + return wslInfo.linuxPath + } - return arg - }) + // Windows drive path (e.g. C:\Users\...) → /mnt/c/Users/... + const driveMatch = arg.match(/^([A-Za-z]):[/\\](.*)$/) + if (driveMatch) { + const driveLetter = driveMatch[1].toLowerCase() + const rest = driveMatch[2].replace(/\\/g, '/') + return `/mnt/${driveLetter}/${rest}` + } + + return arg } function hasExplicitRepoArg(args: string[]): boolean { @@ -161,7 +168,8 @@ function resolveCommand( command: string, args: string[], cwd: string | undefined, - wslDistroOverride?: string + wslDistroOverride?: string, + options: { useWslLoginShell?: boolean } = {} ): ResolvedCommand { if (process.platform !== 'win32') { return { binary: command, args, cwd, wsl: null } @@ -185,18 +193,36 @@ function resolveCommand( } const translatedArgs = translateArgsForWsl(args) + const escapedCommand = quotePosixShell(command) // Why: shell-escape each argument to prevent word splitting / glob expansion // inside the bash -c string. Single quotes are safe for all chars except // single quotes themselves, which we escape as '\'' (end quote, escaped // literal, reopen quote). - const escapedArgs = translatedArgs.map((a) => `'${a.replace(/'/g, "'\\''")}'`) + const escapedArgs = translatedArgs.map(quotePosixShell) // Why: when cwd is supplied as a WSL UNC path, prepend `cd &&` // so the command runs in the expected directory. When the caller only // supplied a distro override (no cwd), skip the cd entirely — the gh CLI // doesn't need a particular cwd for global calls like `api rate_limit`. - const shellCmd = cwdWsl - ? `cd '${cwdWsl.linuxPath.replace(/'/g, "'\\''")}' && ${command} ${escapedArgs.join(' ')}` - : `${command} ${escapedArgs.join(' ')}` + const linuxCwd = cwdWsl?.linuxPath ?? (cwd && wslDistroOverride ? translateArgForWsl(cwd) : null) + const shellCmd = linuxCwd + ? `cd ${quotePosixShell(linuxCwd)} && ${escapedCommand} ${escapedArgs.join(' ')}` + : `${escapedCommand} ${escapedArgs.join(' ')}` + + if (options.useWslLoginShell) { + return { + binary: 'wsl.exe', + args: [ + '-d', + wsl.distro, + '--', + 'sh', + '-lc', + escapeWslShCommandForWindows(buildWslLoginShellCommand(shellCmd)) + ], + cwd: undefined, + wsl + } + } return { binary: 'wsl.exe', @@ -226,6 +252,7 @@ type GitExecOptions = { timeout?: number env?: NodeJS.ProcessEnv signal?: AbortSignal + wslDistro?: string } type CommandExecOptions = { @@ -533,7 +560,9 @@ export async function gitExecFileAsync( return withGitSpan( { args, ...(options.cwd !== undefined ? { cwd: options.cwd } : {}) }, async () => { - const resolved = resolveCommand('git', args, options.cwd) + const resolved = resolveCommand('git', args, options.cwd, options.wslDistro, { + useWslLoginShell: Boolean(options.wslDistro) + }) const { stdout, stderr } = await execFileCapture(resolved.binary, resolved.args, { cwd: resolved.cwd, encoding: (options.encoding ?? 'utf-8') as BufferEncoding, @@ -598,9 +627,11 @@ export async function commandExecFileAsync( */ export async function gitExecFileAsyncBuffer( args: string[], - options: { cwd: string; maxBuffer?: number } + options: { cwd: string; maxBuffer?: number; wslDistro?: string } ): Promise<{ stdout: Buffer }> { - const resolved = resolveCommand('git', args, options.cwd) + const resolved = resolveCommand('git', args, options.cwd, options.wslDistro, { + useWslLoginShell: Boolean(options.wslDistro) + }) const { stdout } = (await execFileCapture(resolved.binary, resolved.args, { cwd: resolved.cwd, encoding: 'buffer', @@ -616,6 +647,7 @@ export type GitStreamResult = { stoppedEarly: boolean } type GitStreamOptions = { cwd: string env?: NodeJS.ProcessEnv + wslDistro?: string /** Byte backstop; defaults to DEFAULT_GIT_MAX_BUFFER. */ maxBuffer?: number /** @@ -647,6 +679,7 @@ export async function gitStreamStdout( cwd: options.cwd, env: nonInteractiveGitEnv(options.env), stdio: ['ignore', 'pipe', 'pipe'], + wslDistro: options.wslDistro, windowsHide: true }) @@ -767,10 +800,16 @@ export function gitExecFileSync( * Spawn a git child process. Drop-in replacement for * `spawn('git', args, { cwd, stdio, ... })`. */ -export function gitSpawn(args: string[], options: SpawnOptions & { cwd: string }): ChildProcess { - const resolved = resolveCommand('git', args, options.cwd) +export function gitSpawn( + args: string[], + options: SpawnOptions & { cwd: string; wslDistro?: string } +): ChildProcess { + const { wslDistro, ...spawnOptions } = options + const resolved = resolveCommand('git', args, options.cwd, wslDistro, { + useWslLoginShell: Boolean(wslDistro) + }) return spawn(resolved.binary, resolved.args, { - ...options, + ...spawnOptions, cwd: resolved.cwd }) } @@ -1191,11 +1230,14 @@ export async function glabExecFileAsync( export function wslAwareSpawn( command: string, args: string[], - options: SpawnOptions & { cwd?: string } + options: SpawnOptions & { cwd?: string; wslDistro?: string; useWslLoginShell?: boolean } ): ChildProcess { - const resolved = resolveCommand(command, args, options.cwd) + const { wslDistro, useWslLoginShell, ...spawnOptions } = options + const resolved = resolveCommand(command, args, options.cwd, wslDistro, { + useWslLoginShell + }) return spawn(resolved.binary, resolved.args, { - ...options, + ...spawnOptions, cwd: resolved.cwd }) } @@ -1209,16 +1251,21 @@ export function wslAwareSpawn( * are Linux-native (/home/user/repo). The rest of Orca needs Windows UNC * paths (\\wsl.localhost\Ubuntu\home\user\repo) to read files via Node fs. */ -export function translateWslOutputPaths(output: string, originalCwd: string): string { +export function translateWslOutputPaths( + output: string, + originalCwd: string, + options: { wslDistro?: string } = {} +): string { const wsl = parseWslPath(originalCwd) - if (!wsl) { + const distro = wsl?.distro ?? options.wslDistro + if (!distro) { return output } // Replace absolute Linux paths that start with / and look like filesystem // paths in structured git output (e.g. "worktree /home/user/repo/feature") return output.replace(/(?<=worktree )(\/.+)$/gm, (_match, linuxPath: string) => - toWindowsWslPath(linuxPath, wsl.distro) + toWindowsWslPath(linuxPath, distro) ) } diff --git a/src/main/git/status-cquoted-paths.test.ts b/src/main/git/status-cquoted-paths.test.ts index 0008b21bc7a..b937f6dbcdb 100644 --- a/src/main/git/status-cquoted-paths.test.ts +++ b/src/main/git/status-cquoted-paths.test.ts @@ -26,9 +26,9 @@ afterEach(async () => { }) describe('git status C-quoted paths', () => { - it('returns the real path for untracked files whose names contain tabs', async () => { + it('returns the real path for untracked UTF-8 filenames', async () => { const repo = await createRepo() - const filePath = 'tab\tfile.txt' + const filePath = '日本語-file.txt' await writeFile(path.join(repo, filePath), 'new file\n') const status = await getStatus(repo) diff --git a/src/main/git/status-discard-symlink.test.ts b/src/main/git/status-discard-symlink.test.ts index 88a22d06009..6adc4257bf9 100644 --- a/src/main/git/status-discard-symlink.test.ts +++ b/src/main/git/status-discard-symlink.test.ts @@ -40,6 +40,13 @@ afterEach(async () => { }) describe('discardChanges symlink safety', () => { + const globNamedFile = '[k]eep.log' + const globMatchedFile = 'keep.log' + + function gitLiteralPathspec(filePath: string): string { + return `:(literal)${filePath}` + } + it('rejects an untracked child path through a symlinked parent', async () => { const { repo, outsideDir, outsideFile } = await createRepoWithOutsideDirectory() await createDirectoryLink(outsideDir, path.join(repo, 'link')) @@ -96,30 +103,30 @@ describe('discardChanges symlink safety', () => { await writeFile(path.join(repo, '.gitignore'), 'ignored.log\n') execFileSync('git', ['add', '.gitignore'], { cwd: repo }) execFileSync('git', ['commit', '-q', '-m', 'ignore log fixture'], { cwd: repo }) - await writeFile(path.join(repo, '*.log'), 'selected') - await writeFile(path.join(repo, 'keep.log'), 'unrelated') + await writeFile(path.join(repo, globNamedFile), 'selected') + await writeFile(path.join(repo, globMatchedFile), 'unrelated') await writeFile(path.join(repo, 'ignored.log'), 'ignored') - await discardChanges(repo, '*.log') + await discardChanges(repo, globNamedFile) - await expect(access(path.join(repo, '*.log'))).rejects.toThrow() - await expect(access(path.join(repo, 'keep.log'))).resolves.toBeUndefined() + await expect(access(path.join(repo, globNamedFile))).rejects.toThrow() + await expect(access(path.join(repo, globMatchedFile))).resolves.toBeUndefined() await expect(access(path.join(repo, 'ignored.log'))).resolves.toBeUndefined() }) it('treats tracked discard paths with Git glob characters as literal paths', async () => { const { repo } = await createRepoWithOutsideDirectory() - await writeFile(path.join(repo, '*.log'), 'selected') - await writeFile(path.join(repo, 'keep.log'), 'keep') - execFileSync('git', ['add', '*.log', 'keep.log'], { cwd: repo }) + await writeFile(path.join(repo, globNamedFile), 'selected') + await writeFile(path.join(repo, globMatchedFile), 'keep') + execFileSync('git', ['add', gitLiteralPathspec(globNamedFile), globMatchedFile], { cwd: repo }) execFileSync('git', ['commit', '-q', '-m', 'track log fixtures'], { cwd: repo }) - await writeFile(path.join(repo, '*.log'), 'selected modified') - await writeFile(path.join(repo, 'keep.log'), 'keep modified') + await writeFile(path.join(repo, globNamedFile), 'selected modified') + await writeFile(path.join(repo, globMatchedFile), 'keep modified') - await discardChanges(repo, '*.log') + await discardChanges(repo, globNamedFile) - await expect(readFile(path.join(repo, '*.log'), 'utf8')).resolves.toBe('selected') - await expect(readFile(path.join(repo, 'keep.log'), 'utf8')).resolves.toBe('keep modified') + await expect(readFile(path.join(repo, globNamedFile), 'utf8')).resolves.toBe('selected') + await expect(readFile(path.join(repo, globMatchedFile), 'utf8')).resolves.toBe('keep modified') }) it('treats bulk untracked discard paths with Git glob characters as literal paths', async () => { @@ -127,14 +134,14 @@ describe('discardChanges symlink safety', () => { await writeFile(path.join(repo, '.gitignore'), 'ignored.log\n') execFileSync('git', ['add', '.gitignore'], { cwd: repo }) execFileSync('git', ['commit', '-q', '-m', 'ignore log fixture'], { cwd: repo }) - await writeFile(path.join(repo, '*.log'), 'selected') - await writeFile(path.join(repo, 'keep.log'), 'unrelated') + await writeFile(path.join(repo, globNamedFile), 'selected') + await writeFile(path.join(repo, globMatchedFile), 'unrelated') await writeFile(path.join(repo, 'ignored.log'), 'ignored') - await bulkDiscardChanges(repo, ['*.log']) + await bulkDiscardChanges(repo, [globNamedFile]) - await expect(access(path.join(repo, '*.log'))).rejects.toThrow() - await expect(access(path.join(repo, 'keep.log'))).resolves.toBeUndefined() + await expect(access(path.join(repo, globNamedFile))).rejects.toThrow() + await expect(access(path.join(repo, globMatchedFile))).resolves.toBeUndefined() await expect(access(path.join(repo, 'ignored.log'))).resolves.toBeUndefined() }) diff --git a/src/main/git/status-pathspec-literals.test.ts b/src/main/git/status-pathspec-literals.test.ts index 31d66bcdba9..befc40b2efe 100644 --- a/src/main/git/status-pathspec-literals.test.ts +++ b/src/main/git/status-pathspec-literals.test.ts @@ -6,6 +6,12 @@ import { afterEach, describe, expect, it } from 'vitest' import { bulkStageFiles, bulkUnstageFiles, stageFile, unstageFile } from './status' const tempRoots: string[] = [] +const globNamedFile = '[k]eep.log' +const globMatchedFile = 'keep.log' + +function gitLiteralPathspec(filePath: string): string { + return `:(literal)${filePath}` +} async function createRepoWithGlobNamedFiles(): Promise { const repo = await mkdtemp(path.join(tmpdir(), 'orca-status-pathspec-')) @@ -13,12 +19,12 @@ async function createRepoWithGlobNamedFiles(): Promise { execFileSync('git', ['init', '-q'], { cwd: repo }) execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repo }) execFileSync('git', ['config', 'user.name', 'Test User'], { cwd: repo }) - await writeFile(path.join(repo, '*.log'), 'selected') - await writeFile(path.join(repo, 'keep.log'), 'keep') - execFileSync('git', ['add', '*.log', 'keep.log'], { cwd: repo }) + await writeFile(path.join(repo, globNamedFile), 'selected') + await writeFile(path.join(repo, globMatchedFile), 'keep') + execFileSync('git', ['add', gitLiteralPathspec(globNamedFile), globMatchedFile], { cwd: repo }) execFileSync('git', ['commit', '-q', '-m', 'initial'], { cwd: repo }) - await writeFile(path.join(repo, '*.log'), 'selected modified') - await writeFile(path.join(repo, 'keep.log'), 'keep modified') + await writeFile(path.join(repo, globNamedFile), 'selected modified') + await writeFile(path.join(repo, globMatchedFile), 'keep modified') return repo } @@ -35,38 +41,38 @@ describe('git status pathspec literals', () => { it('stages a tracked path with Git glob characters as one literal path', async () => { const repo = await createRepoWithGlobNamedFiles() - await stageFile(repo, '*.log') + await stageFile(repo, globNamedFile) - expect(gitNames(repo, ['diff', '--cached', '--name-only'])).toEqual(['*.log']) - expect(gitNames(repo, ['diff', '--name-only'])).toEqual(['keep.log']) + expect(gitNames(repo, ['diff', '--cached', '--name-only'])).toEqual([globNamedFile]) + expect(gitNames(repo, ['diff', '--name-only'])).toEqual([globMatchedFile]) }) it('bulk stages tracked paths with Git glob characters as literal paths', async () => { const repo = await createRepoWithGlobNamedFiles() - await bulkStageFiles(repo, ['*.log']) + await bulkStageFiles(repo, [globNamedFile]) - expect(gitNames(repo, ['diff', '--cached', '--name-only'])).toEqual(['*.log']) - expect(gitNames(repo, ['diff', '--name-only'])).toEqual(['keep.log']) + expect(gitNames(repo, ['diff', '--cached', '--name-only'])).toEqual([globNamedFile]) + expect(gitNames(repo, ['diff', '--name-only'])).toEqual([globMatchedFile]) }) it('unstages a tracked path with Git glob characters as one literal path', async () => { const repo = await createRepoWithGlobNamedFiles() - execFileSync('git', ['add', '*.log', 'keep.log'], { cwd: repo }) + execFileSync('git', ['add', gitLiteralPathspec(globNamedFile), globMatchedFile], { cwd: repo }) - await unstageFile(repo, '*.log') + await unstageFile(repo, globNamedFile) - expect(gitNames(repo, ['diff', '--cached', '--name-only'])).toEqual(['keep.log']) - expect(gitNames(repo, ['diff', '--name-only'])).toEqual(['*.log']) + expect(gitNames(repo, ['diff', '--cached', '--name-only'])).toEqual([globMatchedFile]) + expect(gitNames(repo, ['diff', '--name-only'])).toEqual([globNamedFile]) }) it('bulk unstages tracked paths with Git glob characters as literal paths', async () => { const repo = await createRepoWithGlobNamedFiles() - execFileSync('git', ['add', '*.log', 'keep.log'], { cwd: repo }) + execFileSync('git', ['add', gitLiteralPathspec(globNamedFile), globMatchedFile], { cwd: repo }) - await bulkUnstageFiles(repo, ['*.log']) + await bulkUnstageFiles(repo, [globNamedFile]) - expect(gitNames(repo, ['diff', '--cached', '--name-only'])).toEqual(['keep.log']) - expect(gitNames(repo, ['diff', '--name-only'])).toEqual(['*.log']) + expect(gitNames(repo, ['diff', '--cached', '--name-only'])).toEqual([globMatchedFile]) + expect(gitNames(repo, ['diff', '--name-only'])).toEqual([globNamedFile]) }) }) diff --git a/src/main/git/status.ts b/src/main/git/status.ts index 2909aa08a08..e2c134fd939 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -45,6 +45,8 @@ import { import { resolveWorktreeAddBaseRef } from '../../shared/worktree-base-ref' import { hasWorktreeBaseCommitRef } from './worktree-base-ref-probe' import { getLargeDiffRenderLimit } from '../../shared/large-diff-render-limit' +import type { GitRuntimeOptions } from './git-runtime-options' +import { gitOptionsForWorktree } from './git-runtime-options' const MAX_GIT_SHOW_BYTES = 10 * 1024 * 1024 const MAX_STAGED_COMMIT_CONTEXT_BYTES = MAX_GIT_SHOW_BYTES @@ -64,7 +66,7 @@ export function clearEffectiveUpstreamStatusCacheForTests(): void { effectiveUpstreamStatusInFlight.clear() } -export type GetStatusOptions = { +export type GetStatusOptions = GitRuntimeOptions & { includeIgnored?: boolean /** * Max changed-file entries before git is stopped and the result is marked @@ -119,6 +121,7 @@ export async function getStatus( try { const { stoppedEarly } = await gitStreamStdout(statusArgs, { cwd: worktreePath, + wslDistro: options.wslDistro, // Why: status polling is read-like; avoid refreshing the index and racing // terminal Git commands on `.git/worktrees/*/index.lock`. env: gitOptionalLocksDisabledEnv(), @@ -153,12 +156,18 @@ export async function getStatus( if (statusSucceeded && !didHitLimit && shouldProbeEffectiveUpstreamStatus(branch, upstreamName)) { const branchName = getShortBranchName(branch) if (branchName) { - const cacheKey = getEffectiveUpstreamStatusCacheKey(worktreePath, branchName, upstreamName) + const cacheKey = getEffectiveUpstreamStatusCacheKey( + worktreePath, + branchName, + upstreamName, + options + ) try { effectiveUpstreamStatus = await readOrProbeEffectiveUpstreamStatus( cacheKey, worktreePath, - branchName + branchName, + options ) } catch { // Why: git status polling should not fail just because the richer @@ -174,7 +183,7 @@ export async function getStatus( // running numstat over a huge change set would reintroduce the cost the limit // exists to avoid, matching how a "huge" repo disables extra git features. if (!didHitLimit) { - await attachLineStats(worktreePath, entries) + await attachLineStats(worktreePath, entries, options) } return { @@ -203,7 +212,8 @@ export async function getStatus( async function runNumstat( worktreePath: string, - cached: boolean + cached: boolean, + options: GitRuntimeOptions = {} ): Promise> { try { const { stdout } = await gitExecFileAsync( @@ -216,7 +226,7 @@ async function runNumstat( '--numstat', '-M' ], - { cwd: worktreePath, env: gitOptionalLocksDisabledEnv() } + { ...gitOptionsForWorktree(worktreePath, options), env: gitOptionalLocksDisabledEnv() } ) return parseNumstat(stdout) } catch { @@ -226,7 +236,11 @@ async function runNumstat( } } -async function attachLineStats(worktreePath: string, entries: GitStatusEntry[]): Promise { +async function attachLineStats( + worktreePath: string, + entries: GitStatusEntry[], + options: GitRuntimeOptions = {} +): Promise { if (entries.length === 0) { return } @@ -237,8 +251,8 @@ async function attachLineStats(worktreePath: string, entries: GitStatusEntry[]): .map((entry) => entry.path) const emptyStats = new Map() const [stagedStats, unstagedStats, untrackedStats] = await Promise.all([ - hasStaged ? runNumstat(worktreePath, true) : Promise.resolve(emptyStats), - hasUnstaged ? runNumstat(worktreePath, false) : Promise.resolve(emptyStats), + hasStaged ? runNumstat(worktreePath, true, options) : Promise.resolve(emptyStats), + hasUnstaged ? runNumstat(worktreePath, false, options) : Promise.resolve(emptyStats), collectUntrackedAdditions(worktreePath, untrackedPaths) ]) for (const entry of entries) { @@ -261,9 +275,10 @@ function getShortBranchName(branch: string | undefined): string | null { function getEffectiveUpstreamStatusCacheKey( worktreePath: string, branchName: string, - upstreamName: string | undefined + upstreamName: string | undefined, + options: GitRuntimeOptions = {} ): string { - return [worktreePath, branchName, upstreamName ?? ''].join('\0') + return [worktreePath, options.wslDistro ?? 'host', branchName, upstreamName ?? ''].join('\0') } function readCachedEffectiveUpstreamStatus( @@ -307,7 +322,8 @@ function rememberEffectiveUpstreamStatus( async function readOrProbeEffectiveUpstreamStatus( cacheKey: string, worktreePath: string, - branchName: string + branchName: string, + options: GitRuntimeOptions = {} ): Promise { const cached = readCachedEffectiveUpstreamStatus(cacheKey, Date.now()) if (cached) { @@ -321,7 +337,7 @@ async function readOrProbeEffectiveUpstreamStatus( // Why: source-control mount and root git refresh can overlap during startup. // Coalesce the richer upstream probe so a stable missing ref fails once. - const probe = probeEffectiveUpstreamStatus(worktreePath, branchName).then((result) => { + const probe = probeEffectiveUpstreamStatus(worktreePath, branchName, options).then((result) => { rememberEffectiveUpstreamStatus( cacheKey, result.status, @@ -342,14 +358,15 @@ async function readOrProbeEffectiveUpstreamStatus( async function probeEffectiveUpstreamStatus( worktreePath: string, - branchName: string + branchName: string, + options: GitRuntimeOptions = {} ): Promise<{ status: GitUpstreamStatus; probedSameNameOriginRef: boolean }> { let probedSameNameOriginRef = false const status = await getEffectiveGitUpstreamStatus((args) => { if (args[0] === 'rev-parse' && args.includes(`refs/remotes/origin/${branchName}`)) { probedSameNameOriginRef = true } - return gitExecFileAsync(args, { cwd: worktreePath }) + return gitExecFileAsync(args, gitOptionsForWorktree(worktreePath, options)) }) return { status, probedSameNameOriginRef } } @@ -527,12 +544,18 @@ export async function detectConflictOperation(worktreePath: string): Promise { - await gitExecFileAsync(['merge', '--abort'], { cwd: worktreePath }) +export async function abortMerge( + worktreePath: string, + options: GitRuntimeOptions = {} +): Promise { + await gitExecFileAsync(['merge', '--abort'], gitOptionsForWorktree(worktreePath, options)) } -export async function abortRebase(worktreePath: string): Promise { - await gitExecFileAsync(['rebase', '--abort'], { cwd: worktreePath }) +export async function abortRebase( + worktreePath: string, + options: GitRuntimeOptions = {} +): Promise { + await gitExecFileAsync(['rebase', '--abort'], gitOptionsForWorktree(worktreePath, options)) } export async function resolveGitDir(worktreePath: string): Promise { @@ -558,7 +581,8 @@ export async function getDiff( worktreePath: string, filePath: string, staged: boolean, - compareAgainstHead = false + compareAgainstHead = false, + options: GitRuntimeOptions = {} ): Promise { let originalContent = '' let modifiedContent = '' @@ -567,15 +591,15 @@ export async function getDiff( try { const leftBlob = staged - ? await readGitBlobAtOidPath(worktreePath, 'HEAD', filePath) + ? await readGitBlobAtOidPath(worktreePath, 'HEAD', filePath, options) : compareAgainstHead - ? await readGitBlobAtOidPath(worktreePath, 'HEAD', filePath) - : await readUnstagedLeftBlob(worktreePath, filePath) + ? await readGitBlobAtOidPath(worktreePath, 'HEAD', filePath, options) + : await readUnstagedLeftBlob(worktreePath, filePath, options) originalContent = leftBlob.content originalIsBinary = leftBlob.isBinary if (staged) { - const rightBlob = await readGitBlobAtIndexPath(worktreePath, filePath) + const rightBlob = await readGitBlobAtIndexPath(worktreePath, filePath, options) modifiedContent = rightBlob.content modifiedIsBinary = rightBlob.isBinary } else { @@ -598,7 +622,8 @@ export async function getDiff( export async function getBranchCompare( worktreePath: string, - baseRef: string + baseRef: string, + options: GitRuntimeOptions = {} ): Promise { const summary: GitBranchCompareSummary = { baseRef, @@ -610,22 +635,22 @@ export async function getBranchCompare( status: 'loading' } - const compareRef = await resolveCompareRef(worktreePath) + const compareRef = await resolveCompareRef(worktreePath, options) summary.compareRef = compareRef // Why: short remote display refs like "origin/main" can collide with a local // branch of the same name. Compare against the proven remote-tracking ref. const resolvedBaseRef = await resolveWorktreeAddBaseRef(baseRef, (qualifiedRef) => - hasWorktreeBaseCommitRef(worktreePath, qualifiedRef) + hasWorktreeBaseCommitRef(worktreePath, qualifiedRef, options) ) let headOid = '' let baseOid = '' try { - headOid = await resolveRefOid(worktreePath, 'HEAD') + headOid = await resolveRefOid(worktreePath, 'HEAD', options) summary.headOid = headOid } catch { try { - baseOid = await resolveRefOid(worktreePath, resolvedBaseRef) + baseOid = await resolveRefOid(worktreePath, resolvedBaseRef, options) summary.baseOid = baseOid // Why: new remote worktrees can be on an unborn branch until the first // commit. There are no committed branch changes yet; surfacing this as a @@ -645,7 +670,7 @@ export async function getBranchCompare( } try { - baseOid = await resolveRefOid(worktreePath, resolvedBaseRef) + baseOid = await resolveRefOid(worktreePath, resolvedBaseRef, options) summary.baseOid = baseOid } catch { summary.status = 'invalid-base' @@ -655,7 +680,7 @@ export async function getBranchCompare( let mergeBase = '' try { - mergeBase = await resolveMergeBase(worktreePath, baseOid, headOid) + mergeBase = await resolveMergeBase(worktreePath, baseOid, headOid, options) summary.mergeBase = mergeBase } catch { summary.status = 'no-merge-base' @@ -665,8 +690,8 @@ export async function getBranchCompare( try { const [entries, commitsAhead] = await Promise.all([ - loadBranchChanges(worktreePath, mergeBase, headOid), - countAheadCommits(worktreePath, baseOid, headOid) + loadBranchChanges(worktreePath, mergeBase, headOid, options), + countAheadCommits(worktreePath, baseOid, headOid, options) ]) summary.changedFiles = entries.length summary.commitsAhead = commitsAhead @@ -686,12 +711,13 @@ export async function getBranchDiff( mergeBase: string filePath: string oldPath?: string - } + }, + options: GitRuntimeOptions = {} ): Promise { try { const leftPath = args.oldPath ?? args.filePath - const leftBlob = await readGitBlobAtOidPath(worktreePath, args.mergeBase, leftPath) - const rightBlob = await readGitBlobAtOidPath(worktreePath, args.headOid, args.filePath) + const leftBlob = await readGitBlobAtOidPath(worktreePath, args.mergeBase, leftPath, options) + const rightBlob = await readGitBlobAtOidPath(worktreePath, args.headOid, args.filePath, options) return buildDiffResult( leftBlob.content, @@ -713,11 +739,12 @@ export async function getBranchDiff( export async function getCommitCompare( worktreePath: string, - commitId: string + commitId: string, + options: GitRuntimeOptions = {} ): Promise { let commitOid = '' try { - commitOid = await resolveRefOid(worktreePath, `${commitId}^{commit}`) + commitOid = await resolveRefOid(worktreePath, `${commitId}^{commit}`, options) } catch { return { summary: { @@ -743,14 +770,15 @@ export async function getCommitCompare( } try { - const { stdout } = await gitExecFileAsync(['rev-list', '--parents', '-n', '1', commitOid], { - cwd: worktreePath - }) + const { stdout } = await gitExecFileAsync( + ['rev-list', '--parents', '-n', '1', commitOid], + gitOptionsForWorktree(worktreePath, options) + ) const [, firstParent] = stdout.trim().split(/\s+/) summary.parentOid = firstParent ?? null summary.baseRef = firstParent ? firstParent.slice(0, 7) : 'empty tree' - const entries = await loadCommitChanges(worktreePath, summary.parentOid, commitOid) + const entries = await loadCommitChanges(worktreePath, summary.parentOid, commitOid, options) summary.changedFiles = entries.length return { summary, entries } } catch (error) { @@ -772,14 +800,20 @@ export async function getCommitDiff( parentOid?: string | null filePath: string oldPath?: string - } + }, + options: GitRuntimeOptions = {} ): Promise { try { const leftPath = args.oldPath ?? args.filePath const leftBlob = args.parentOid - ? await readGitBlobAtOidPath(worktreePath, args.parentOid, leftPath) + ? await readGitBlobAtOidPath(worktreePath, args.parentOid, leftPath, options) : { content: '', isBinary: false } - const rightBlob = await readGitBlobAtOidPath(worktreePath, args.commitOid, args.filePath) + const rightBlob = await readGitBlobAtOidPath( + worktreePath, + args.commitOid, + args.filePath, + options + ) return buildDiffResult( leftBlob.content, @@ -802,11 +836,15 @@ export async function getCommitDiff( async function loadBranchChanges( worktreePath: string, mergeBase: string, - headOid: string + headOid: string, + options: GitRuntimeOptions = {} ): Promise { // Why: see core.quotePath=false rationale in getStatus — same reason here so // branch-diff entries render with their real UTF-8 paths. - const gitOptions = { cwd: worktreePath, maxBuffer: MAX_GIT_SHOW_BYTES } + const gitOptions = { + ...gitOptionsForWorktree(worktreePath, options), + maxBuffer: MAX_GIT_SHOW_BYTES + } // Why: both diffs walk the same range and are independent, so start them // together instead of serializing two potentially large git operations. const [{ stdout }, { stdout: numstat }] = await Promise.all([ @@ -839,7 +877,8 @@ async function loadBranchChanges( async function loadCommitChanges( worktreePath: string, parentOid: string | null, - commitOid: string + commitOid: string, + options: GitRuntimeOptions = {} ): Promise { // Why: root commits have no parent tree; diff-tree --root asks git to // compare against the repository's empty tree without hardcoding hash format. @@ -872,7 +911,10 @@ async function loadCommitChanges( '-C', commitOid ] - const gitOptions = { cwd: worktreePath, maxBuffer: MAX_GIT_SHOW_BYTES } + const gitOptions = { + ...gitOptionsForWorktree(worktreePath, options), + maxBuffer: MAX_GIT_SHOW_BYTES + } // Why: commit diff rows need metadata and line counts, but those git queries // do not depend on each other. const [{ stdout }, { stdout: numstat }] = await Promise.all([ @@ -916,10 +958,13 @@ function parseBranchChangeLine(line: string): GitBranchChangeEntry | null { return { path, status } } -async function resolveCompareRef(worktreePath: string): Promise { +async function resolveCompareRef( + worktreePath: string, + options: GitRuntimeOptions = {} +): Promise { try { const { stdout } = await gitExecFileAsync(['branch', '--show-current'], { - cwd: worktreePath + ...gitOptionsForWorktree(worktreePath, options) }) const branch = stdout.trim() return branch || 'HEAD' @@ -928,9 +973,13 @@ async function resolveCompareRef(worktreePath: string): Promise { } } -async function resolveRefOid(worktreePath: string, ref: string): Promise { +async function resolveRefOid( + worktreePath: string, + ref: string, + options: GitRuntimeOptions = {} +): Promise { const { stdout } = await gitExecFileAsync(['rev-parse', '--verify', '--end-of-options', ref], { - cwd: worktreePath + ...gitOptionsForWorktree(worktreePath, options) }) return stdout.trim() } @@ -938,10 +987,11 @@ async function resolveRefOid(worktreePath: string, ref: string): Promise async function resolveMergeBase( worktreePath: string, baseOid: string, - headOid: string + headOid: string, + options: GitRuntimeOptions = {} ): Promise { const { stdout } = await gitExecFileAsync(['merge-base', baseOid, headOid], { - cwd: worktreePath + ...gitOptionsForWorktree(worktreePath, options) }) return stdout.trim() } @@ -949,35 +999,38 @@ async function resolveMergeBase( async function countAheadCommits( worktreePath: string, baseOid: string, - headOid: string + headOid: string, + options: GitRuntimeOptions = {} ): Promise { const { stdout } = await gitExecFileAsync(['rev-list', '--count', `${baseOid}..${headOid}`], { - cwd: worktreePath + ...gitOptionsForWorktree(worktreePath, options) }) return Number.parseInt(stdout.trim(), 10) || 0 } async function readUnstagedLeftBlob( worktreePath: string, - filePath: string + filePath: string, + options: GitRuntimeOptions = {} ): Promise { - const indexBlob = await readGitBlobAtIndexPath(worktreePath, filePath) + const indexBlob = await readGitBlobAtIndexPath(worktreePath, filePath, options) if (indexBlob.exists) { return indexBlob } - return readGitBlobAtOidPath(worktreePath, 'HEAD', filePath) + return readGitBlobAtOidPath(worktreePath, 'HEAD', filePath, options) } async function readGitBlobAtIndexPath( worktreePath: string, - filePath: string + filePath: string, + options: GitRuntimeOptions = {} ): Promise { // Why: Git's `:` syntax expects forward slashes even on Windows. const gitPath = filePath.replace(/\\/g, '/') try { const { stdout } = await gitExecFileAsyncBuffer(['show', `:${gitPath}`], { - cwd: worktreePath, + ...gitOptionsForWorktree(worktreePath, options), maxBuffer: MAX_GIT_SHOW_BYTES }) @@ -993,7 +1046,8 @@ async function readGitBlobAtIndexPath( async function readGitBlobAtOidPath( worktreePath: string, oid: string, - filePath: string + filePath: string, + options: GitRuntimeOptions = {} ): Promise { // Why: Git's `:` syntax expects forward slashes even on Windows. const gitPath = filePath.replace(/\\/g, '/') @@ -1001,7 +1055,7 @@ async function readGitBlobAtOidPath( const { stdout } = await gitExecFileAsyncBuffer( ['show', '--end-of-options', `${oid}:${gitPath}`], { - cwd: worktreePath, + ...gitOptionsForWorktree(worktreePath, options), maxBuffer: MAX_GIT_SHOW_BYTES } ) @@ -1118,27 +1172,39 @@ const PREVIEWABLE_BINARY_MIME_TYPES: Record = { /** * Stage a file. */ -export async function stageFile(worktreePath: string, filePath: string): Promise { - await gitExecFileAsync(['add', '--', literalPathspec(filePath)], { cwd: worktreePath }) +export async function stageFile( + worktreePath: string, + filePath: string, + options: GitRuntimeOptions = {} +): Promise { + await gitExecFileAsync( + ['add', '--', literalPathspec(filePath)], + gitOptionsForWorktree(worktreePath, options) + ) } /** * Unstage a file. */ -export async function unstageFile(worktreePath: string, filePath: string): Promise { +export async function unstageFile( + worktreePath: string, + filePath: string, + options: GitRuntimeOptions = {} +): Promise { await gitExecFileAsync(['restore', '--staged', '--', literalPathspec(filePath)], { - cwd: worktreePath + ...gitOptionsForWorktree(worktreePath, options) }) } export async function getStagedCommitContext( - worktreePath: string + worktreePath: string, + options: GitRuntimeOptions = {} ): Promise { const branchPromise = gitExecFileAsync(['branch', '--show-current'], { - cwd: worktreePath + ...gitOptionsForWorktree(worktreePath, options) }).catch(() => ({ stdout: '' })) const summaryPromise = gitExecFileAsync(['diff', '--cached', '--name-status'], { - cwd: worktreePath, + ...gitOptionsForWorktree(worktreePath, options), maxBuffer: MAX_STAGED_COMMIT_CONTEXT_BYTES }) @@ -1153,7 +1219,7 @@ export async function getStagedCommitContext( const patchResult = await gitExecFileAsync( ['diff', '--cached', '--patch', '--minimal', '--no-color', '--no-ext-diff'], { - cwd: worktreePath, + ...gitOptionsForWorktree(worktreePath, options), maxBuffer: MAX_STAGED_COMMIT_CONTEXT_BYTES } ) @@ -1180,10 +1246,11 @@ export async function getStagedCommitContext( export async function commitChanges( worktreePath: string, - message: string + message: string, + options: GitRuntimeOptions = {} ): Promise<{ success: boolean; error?: string }> { try { - await gitExecFileAsync(['commit', '-m', message], { cwd: worktreePath }) + await gitExecFileAsync(['commit', '-m', message], gitOptionsForWorktree(worktreePath, options)) return { success: true } } catch (error) { // Why: surface whichever channel carries the useful message. Pre-commit/GPG @@ -1209,7 +1276,11 @@ export async function commitChanges( /** * Discard working tree changes for a file. */ -export async function discardChanges(worktreePath: string, filePath: string): Promise { +export async function discardChanges( + worktreePath: string, + filePath: string, + options: GitRuntimeOptions = {} +): Promise { const resolvedWorktree = path.resolve(worktreePath) const resolvedTarget = path.resolve(worktreePath, filePath) if (!isWithinWorktree(path, resolvedWorktree, resolvedTarget)) { @@ -1219,7 +1290,7 @@ export async function discardChanges(worktreePath: string, filePath: string): Pr let tracked = false try { await gitExecFileAsync(['ls-files', '--error-unmatch', '--', literalPathspec(filePath)], { - cwd: worktreePath + ...gitOptionsForWorktree(worktreePath, options) }) tracked = true } catch { @@ -1230,14 +1301,14 @@ export async function discardChanges(worktreePath: string, filePath: string): Pr await gitExecFileAsync( ['restore', '--worktree', '--source=HEAD', '--', literalPathspec(filePath)], { - cwd: worktreePath + ...gitOptionsForWorktree(worktreePath, options) } ) return } await removeSafeUntrackedDiscardTarget(worktreePath, filePath, (targetPath) => - cleanUntrackedPaths(worktreePath, [targetPath]) + cleanUntrackedPaths(worktreePath, [targetPath], options) ) } @@ -1260,7 +1331,8 @@ function isTrackedPathSpec(filePath: string, trackedPaths: readonly string[]): b async function listTrackedPathSpecs( worktreePath: string, - filePaths: readonly string[] + filePaths: readonly string[], + options: GitRuntimeOptions = {} ): Promise { const trackedPaths: string[] = [] for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) { @@ -1268,7 +1340,7 @@ async function listTrackedPathSpecs( const { stdout } = await gitExecFileAsync( ['ls-files', '-z', '--', ...chunk.map(literalPathspec)], { - cwd: worktreePath + ...gitOptionsForWorktree(worktreePath, options) } ) // Why: a tracked directory can contain enough paths for push(...split) @@ -1284,14 +1356,15 @@ async function listTrackedPathSpecs( async function cleanUntrackedPaths( worktreePath: string, - filePaths: readonly string[] + filePaths: readonly string[], + options: GitRuntimeOptions = {} ): Promise { for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) { const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) if (chunk.length > 0) { // Why: Git pathspec cleanup avoids raw recursive deletion through symlinked parents. await gitExecFileAsync(['clean', '-ffdx', '--', ...chunk.map(literalPathspec)], { - cwd: worktreePath + ...gitOptionsForWorktree(worktreePath, options) }) } } @@ -1300,7 +1373,11 @@ async function cleanUntrackedPaths( /** * Discard working tree changes for many paths in a small number of subprocesses. */ -export async function bulkDiscardChanges(worktreePath: string, filePaths: string[]): Promise { +export async function bulkDiscardChanges( + worktreePath: string, + filePaths: string[], + options: GitRuntimeOptions = {} +): Promise { if (filePaths.length === 0) { return } @@ -1313,7 +1390,7 @@ export async function bulkDiscardChanges(worktreePath: string, filePaths: string } } - const trackedPathSpecs = await listTrackedPathSpecs(worktreePath, filePaths) + const trackedPathSpecs = await listTrackedPathSpecs(worktreePath, filePaths, options) const trackedPaths = filePaths.filter((filePath) => isTrackedPathSpec(filePath, trackedPathSpecs)) const untrackedPaths = filePaths.filter( (filePath) => !isTrackedPathSpec(filePath, trackedPathSpecs) @@ -1321,14 +1398,14 @@ export async function bulkDiscardChanges(worktreePath: string, filePaths: string await removeSafeUntrackedDiscardTargets( worktreePath, untrackedPaths, - (targetPaths) => cleanUntrackedPaths(worktreePath, targetPaths), + (targetPaths) => cleanUntrackedPaths(worktreePath, targetPaths, options), async () => { for (let i = 0; i < trackedPaths.length; i += BULK_CHUNK_SIZE) { const chunk = trackedPaths.slice(i, i + BULK_CHUNK_SIZE) await gitExecFileAsync( ['restore', '--worktree', '--source=HEAD', '--', ...chunk.map(literalPathspec)], { - cwd: worktreePath + ...gitOptionsForWorktree(worktreePath, options) } ) } @@ -1353,27 +1430,38 @@ export function isWithinWorktree( /** * Bulk stage files in batches to avoid E2BIG. */ -export async function bulkStageFiles(worktreePath: string, filePaths: string[]): Promise { +export async function bulkStageFiles( + worktreePath: string, + filePaths: string[], + options: GitRuntimeOptions = {} +): Promise { if (filePaths.length === 0) { return } for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) { const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) - await gitExecFileAsync(['add', '--', ...chunk.map(literalPathspec)], { cwd: worktreePath }) + await gitExecFileAsync( + ['add', '--', ...chunk.map(literalPathspec)], + gitOptionsForWorktree(worktreePath, options) + ) } } /** * Bulk unstage files in batches to avoid E2BIG. */ -export async function bulkUnstageFiles(worktreePath: string, filePaths: string[]): Promise { +export async function bulkUnstageFiles( + worktreePath: string, + filePaths: string[], + options: GitRuntimeOptions = {} +): Promise { if (filePaths.length === 0) { return } for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) { const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) await gitExecFileAsync(['restore', '--staged', '--', ...chunk.map(literalPathspec)], { - cwd: worktreePath + ...gitOptionsForWorktree(worktreePath, options) }) } } diff --git a/src/main/git/upstream.test.ts b/src/main/git/upstream.test.ts index 6264a611f35..61d288bb160 100644 --- a/src/main/git/upstream.test.ts +++ b/src/main/git/upstream.test.ts @@ -473,6 +473,40 @@ describe('getUpstreamStatus', () => { ]) }) + it('routes explicit publish-target probes through the selected WSL distro', async () => { + gitExecFileAsyncMock + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + .mockResolvedValueOnce({ stdout: 'abc123\n', stderr: '' }) + .mockResolvedValueOnce({ stdout: '0\t0\n', stderr: '' }) + + await expect( + getUpstreamStatus( + '/repo', + { + remoteName: 'fork', + branchName: 'feature/fix' + }, + { wslDistro: 'Ubuntu' } + ) + ).resolves.toEqual({ + hasUpstream: true, + upstreamName: 'fork/feature/fix', + ahead: 0, + behind: 0 + }) + expect(gitExecFileAsyncMock.mock.calls).toEqual([ + [['check-ref-format', '--branch', 'feature/fix'], { cwd: '/repo', wslDistro: 'Ubuntu' }], + [ + ['rev-parse', '--verify', '--quiet', 'refs/remotes/fork/feature/fix'], + { cwd: '/repo', wslDistro: 'Ubuntu' } + ], + [ + ['rev-list', '--left-right', '--count', 'HEAD...refs/remotes/fork/feature/fix'], + { cwd: '/repo', wslDistro: 'Ubuntu' } + ] + ]) + }) + it('reports no upstream when an explicit publish target has not been fetched yet', async () => { gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: '', stderr: '' }) diff --git a/src/main/git/upstream.ts b/src/main/git/upstream.ts index ebcf1318cb0..d36b5f0a53d 100644 --- a/src/main/git/upstream.ts +++ b/src/main/git/upstream.ts @@ -6,14 +6,26 @@ import { getPublishTargetStatus } from '../../shared/git-publish-target-status' import { gitExecFileAsync } from './runner' import { validateGitPushTarget } from './push-target-validation' +type GitExecOptions = { + wslDistro?: string +} + +function gitExecOptions( + cwd: string, + options: GitExecOptions = {} +): { cwd: string; wslDistro?: string } { + return options.wslDistro ? { cwd, wslDistro: options.wslDistro } : { cwd } +} + async function getBehindCommitsArePatchEquivalent( worktreePath: string, - upstreamName: string + upstreamName: string, + options: GitExecOptions = {} ): Promise { try { const { stdout } = await gitExecFileAsync( ['log', '--oneline', '--cherry-mark', '--right-only', `HEAD...${upstreamName}`, '--'], - { cwd: worktreePath } + gitExecOptions(worktreePath, options) ) return upstreamOnlyCommitsArePatchEquivalent(stdout) } catch { @@ -25,20 +37,21 @@ async function getBehindCommitsArePatchEquivalent( export async function getUpstreamStatus( worktreePath: string, - pushTarget?: GitPushTarget + pushTarget?: GitPushTarget, + options: GitExecOptions = {} ): Promise { try { if (pushTarget) { - const target = await validateGitPushTarget(worktreePath, pushTarget) + const target = await validateGitPushTarget(worktreePath, pushTarget, options) return await getPublishTargetStatus( - (args) => gitExecFileAsync(args, { cwd: worktreePath }), + (args) => gitExecFileAsync(args, gitExecOptions(worktreePath, options)), target, - (upstreamName) => getBehindCommitsArePatchEquivalent(worktreePath, upstreamName) + (upstreamName) => getBehindCommitsArePatchEquivalent(worktreePath, upstreamName, options) ) } return await getEffectiveGitUpstreamStatus( - (args) => gitExecFileAsync(args, { cwd: worktreePath }), - (upstreamName) => getBehindCommitsArePatchEquivalent(worktreePath, upstreamName) + (args) => gitExecFileAsync(args, gitExecOptions(worktreePath, options)), + (upstreamName) => getBehindCommitsArePatchEquivalent(worktreePath, upstreamName, options) ) } catch (error) { // Why: we only swallow clearly-no-upstream signals — that's an expected diff --git a/src/main/git/worktree-base-ref-probe.ts b/src/main/git/worktree-base-ref-probe.ts index a68fc340e58..0d8e132df40 100644 --- a/src/main/git/worktree-base-ref-probe.ts +++ b/src/main/git/worktree-base-ref-probe.ts @@ -1,12 +1,18 @@ import { gitExecFileAsync } from './runner' +type GitExecOptions = { + wslDistro?: string +} + export async function hasWorktreeBaseCommitRef( repoPath: string, - qualifiedRef: string + qualifiedRef: string, + options: GitExecOptions = {} ): Promise { try { await gitExecFileAsync(['rev-parse', '--verify', '--quiet', `${qualifiedRef}^{commit}`], { - cwd: repoPath + cwd: repoPath, + ...options }) return true } catch { diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts index 2aaabef7c90..45277832a3f 100644 --- a/src/main/git/worktree.ts +++ b/src/main/git/worktree.ts @@ -26,7 +26,11 @@ type SparseWorktreeCreateError = Error & { cleanupFailed?: boolean } -type AddWorktreeOptions = { +export type GitWorktreeExecOptions = { + wslDistro?: string +} + +export type AddWorktreeOptions = GitWorktreeExecOptions & { checkoutExistingBranch?: boolean suggestLocalBaseRefUpdate?: boolean remoteTrackingBase?: { @@ -36,7 +40,7 @@ type AddWorktreeOptions = { } } -export type RemoveWorktreeOptions = { +export type RemoveWorktreeOptions = GitWorktreeExecOptions & { deleteBranch?: boolean forceBranchDelete?: boolean knownRemovedWorktree?: Pick @@ -61,6 +65,13 @@ type LocalBaseRefRefreshability = const SPARSE_CHECKOUT_DETECTION_CONCURRENCY = 8 +function gitExecOptions( + cwd: string, + options: GitWorktreeExecOptions = {} +): { cwd: string; wslDistro?: string } { + return options.wslDistro ? { cwd, wslDistro: options.wslDistro } : { cwd } +} + function getErrorCode(error: unknown): string | undefined { return typeof error === 'object' && error !== null && 'code' in error ? String((error as { code?: unknown }).code) @@ -150,7 +161,8 @@ async function evaluateLocalBaseRefRefreshability( repoPath: string, baseBranch: string, remoteTrackingRef: string, - remoteTrackingBase?: AddWorktreeOptions['remoteTrackingBase'] + remoteTrackingBase?: AddWorktreeOptions['remoteTrackingBase'], + options: GitWorktreeExecOptions = {} ): Promise { const parsed = parseRemoteTrackingLocalBaseRef(baseBranch, remoteTrackingRef, remoteTrackingBase) if (!parsed) { @@ -168,7 +180,7 @@ async function evaluateLocalBaseRefRefreshability( // commits, and tells the toast whether it is actually behind. const { stdout } = await gitExecFileAsync( ['rev-list', '--left-right', '--count', `${parsed.fullRef}...${remoteTrackingRef}`], - { cwd: repoPath } + gitExecOptions(repoPath, options) ) const parsedDrift = parseRevListDrift(stdout) if (!parsedDrift || parsedDrift.ahead !== 0) { @@ -176,7 +188,7 @@ async function evaluateLocalBaseRefRefreshability( } const { stdout: localOidOutput } = await gitExecFileAsync( ['rev-parse', '--verify', `${parsed.fullRef}^{commit}`], - { cwd: repoPath } + gitExecOptions(repoPath, options) ) localOid = localOidOutput.trim() if (!localOid) { @@ -184,13 +196,16 @@ async function evaluateLocalBaseRefRefreshability( } const { stdout: remoteOidOutput } = await gitExecFileAsync( ['rev-parse', '--verify', `${remoteTrackingRef}^{commit}`], - { cwd: repoPath } + gitExecOptions(repoPath, options) ) remoteOid = remoteOidOutput.trim() if (!remoteOid) { return { refreshable: false, result: { ...resultBase, status: 'skipped_not_fast_forward' } } } - await gitExecFileAsync(['merge-base', '--is-ancestor', localOid, remoteOid], { cwd: repoPath }) + await gitExecFileAsync( + ['merge-base', '--is-ancestor', localOid, remoteOid], + gitExecOptions(repoPath, options) + ) drift = parsedDrift } catch { return { refreshable: false, result: { ...resultBase, status: 'skipped_not_fast_forward' } } @@ -201,15 +216,17 @@ async function evaluateLocalBaseRefRefreshability( // setting would only update it when that owner worktree is clean. const { stdout: worktreeListOutput } = await gitExecFileAsync( ['worktree', 'list', '--porcelain'], - { cwd: repoPath } + gitExecOptions(repoPath, options) + ) + const worktrees = parseWorktreeList( + translateWslOutputPaths(worktreeListOutput, repoPath, options) ) - const worktrees = parseWorktreeList(translateWslOutputPaths(worktreeListOutput, repoPath)) const ownerWorktree = worktrees.find((wt) => wt.branch === parsed.fullRef) if (ownerWorktree) { const { stdout: status } = await gitExecFileAsync( ['status', '--porcelain', '--untracked-files=no'], - { cwd: ownerWorktree.path } + gitExecOptions(ownerWorktree.path, options) ) if (status.trim()) { return { @@ -254,13 +271,15 @@ async function getLocalBaseRefUpdateSuggestionForWorktreeCreate( repoPath: string, baseBranch: string, remoteTrackingRef: string, - remoteTrackingBase?: AddWorktreeOptions['remoteTrackingBase'] + remoteTrackingBase?: AddWorktreeOptions['remoteTrackingBase'], + options: GitWorktreeExecOptions = {} ): Promise { const evaluation = await evaluateLocalBaseRefRefreshability( repoPath, baseBranch, remoteTrackingRef, - remoteTrackingBase + remoteTrackingBase, + options ) if (!evaluation?.refreshable || evaluation.behind <= 0) { return undefined @@ -275,12 +294,13 @@ async function getLocalBaseRefUpdateSuggestionForWorktreeCreate( async function persistWorktreeCreationBase( worktreePath: string, branch: string, - effectiveBase: string + effectiveBase: string, + options: GitWorktreeExecOptions = {} ): Promise { const configKey = `branch.${branch}.base` try { await gitExecFileAsync(['config', '--local', '--replace-all', configKey, effectiveBase], { - cwd: worktreePath + ...gitExecOptions(worktreePath, options) }) } catch (error) { console.warn(`addWorktree: failed to set ${configKey} for ${worktreePath}`, error) @@ -288,7 +308,7 @@ async function persistWorktreeCreationBase( // Why: reused branch names may carry stale base metadata; if replacement // fails, remove the old value so consumers do not trust outdated lineage. await gitExecFileAsync(['config', '--local', '--unset-all', configKey], { - cwd: worktreePath + ...gitExecOptions(worktreePath, options) }) } catch (unsetError) { console.warn( @@ -299,10 +319,14 @@ async function persistWorktreeCreationBase( } } -async function unsetWorktreeCreationBase(worktreePath: string, branch: string): Promise { +async function unsetWorktreeCreationBase( + worktreePath: string, + branch: string, + options: GitWorktreeExecOptions = {} +): Promise { try { await gitExecFileAsync(['config', '--local', '--unset-all', `branch.${branch}.base`], { - cwd: worktreePath + ...gitExecOptions(worktreePath, options) }) } catch { // Best-effort cleanup; missing keys and locked config both leave the @@ -412,10 +436,14 @@ function splitNulWorktreeList(output: string): string[][] { return blocks } -async function readWorktreeList(repoPath: string): Promise { +async function readWorktreeList( + repoPath: string, + options: GitWorktreeExecOptions = {} +): Promise { try { const { stdout } = await gitExecFileAsync(['worktree', 'list', '--porcelain', '-z'], { - cwd: repoPath + cwd: repoPath, + ...options }) return parseWorktreeList(stdout, { nulDelimited: true }) } catch (error) { @@ -427,7 +455,8 @@ async function readWorktreeList(repoPath: string): Promise { // Why: `-z` is required to preserve worktree paths containing newlines, but // Git <2.36 rejects it. Keep the old parser as a compatibility fallback. const { stdout } = await gitExecFileAsync(['worktree', 'list', '--porcelain'], { - cwd: repoPath + cwd: repoPath, + ...options }) return parseWorktreeList(stdout) } @@ -435,10 +464,13 @@ async function readWorktreeList(repoPath: string): Promise { /** * List all worktrees for a git repo at the given path. */ -export async function listWorktrees(repoPath: string): Promise { +export async function listWorktrees( + repoPath: string, + options: GitWorktreeExecOptions = {} +): Promise { try { - const worktrees = (await readWorktreeList(repoPath)).map((worktree) => { - const translatedPath = translateWorktreePath(worktree.path, repoPath) + const worktrees = (await readWorktreeList(repoPath, options)).map((worktree) => { + const translatedPath = translateWorktreePath(worktree.path, repoPath, options) return translatedPath === worktree.path ? worktree : { ...worktree, path: translatedPath } }) return annotateSparseCheckoutStatus(worktrees) @@ -496,13 +528,15 @@ async function refreshLocalBaseRefForWorktreeCreate( repoPath: string, baseBranch: string, remoteTrackingRef: string, - remoteTrackingBase?: AddWorktreeOptions['remoteTrackingBase'] + remoteTrackingBase?: AddWorktreeOptions['remoteTrackingBase'], + options: GitWorktreeExecOptions = {} ): Promise { const evaluation = await evaluateLocalBaseRefRefreshability( repoPath, baseBranch, remoteTrackingRef, - remoteTrackingBase + remoteTrackingBase, + options ) if (!evaluation) { return undefined @@ -516,16 +550,18 @@ async function refreshLocalBaseRefForWorktreeCreate( if (evaluation.ownerWorktreePath) { const { stdout: worktreeListOutput } = await gitExecFileAsync( ['worktree', 'list', '--porcelain'], - { cwd: repoPath } + gitExecOptions(repoPath, options) + ) + const worktrees = parseWorktreeList( + translateWslOutputPaths(worktreeListOutput, repoPath, options) ) - const worktrees = parseWorktreeList(translateWslOutputPaths(worktreeListOutput, repoPath)) const currentOwner = worktrees.find((wt) => wt.branch === evaluation.fullRef) if (!currentOwner || currentOwner.path !== evaluation.ownerWorktreePath) { return { ...resultBase, status: 'skipped_error' } } const { stdout: status } = await gitExecFileAsync( ['status', '--porcelain', '--untracked-files=no'], - { cwd: currentOwner.path } + gitExecOptions(currentOwner.path, options) ) if (status.trim()) { return { @@ -534,7 +570,10 @@ async function refreshLocalBaseRefForWorktreeCreate( ownerWorktreePath: currentOwner.path } } - await gitExecFileAsync(['reset', '--hard', evaluation.remoteOid], { cwd: currentOwner.path }) + await gitExecFileAsync( + ['reset', '--hard', evaluation.remoteOid], + gitExecOptions(currentOwner.path, options) + ) return { ...resultBase, status: 'updated', ownerWorktreePath: currentOwner.path } } @@ -543,7 +582,7 @@ async function refreshLocalBaseRefForWorktreeCreate( // evaluation snapshot. await gitExecFileAsync( ['update-ref', evaluation.fullRef, evaluation.remoteOid, evaluation.localOid], - { cwd: repoPath } + gitExecOptions(repoPath, options) ) return { ...resultBase, status: 'updated' } } catch { @@ -592,7 +631,7 @@ export async function addWorktree( args.push('--no-track', '-b', branch, worktreePath) if (baseBranch) { effectiveBase = await resolveWorktreeAddBaseRef(baseBranch, (qualifiedRef) => - hasWorktreeBaseCommitRef(repoPath, qualifiedRef) + hasWorktreeBaseCommitRef(repoPath, qualifiedRef, options) ) // Why: resolving the creation base first distinguishes real // remote-tracking refs from slash-containing local branch names. @@ -603,27 +642,29 @@ export async function addWorktree( repoPath, baseBranch, effectiveBase, - options.remoteTrackingBase + options.remoteTrackingBase, + options ) } else if (options.suggestLocalBaseRefUpdate) { localBaseRefUpdateSuggestion = await getLocalBaseRefUpdateSuggestionForWorktreeCreate( repoPath, baseBranch, effectiveBase, - options.remoteTrackingBase + options.remoteTrackingBase, + options ) } args.push(effectiveBase) } } - await gitExecFileAsync(args, { cwd: repoPath }) + await gitExecFileAsync(args, gitExecOptions(repoPath, options)) if (options.checkoutExistingBranch) { return localBaseRefRefresh ? { localBaseRefRefresh } : {} } if (effectiveBase) { - await persistWorktreeCreationBase(worktreePath, branch, effectiveBase) + await persistWorktreeCreationBase(worktreePath, branch, effectiveBase, options) } // SSH parity: src/relay/git-handler-worktree-ops.ts addWorktreeOp mirrors this exact @@ -661,7 +702,7 @@ export async function addWorktree( let alreadySet = false try { await gitExecFileAsync(['config', '--get', 'push.autoSetupRemote'], { - cwd: worktreePath + ...gitExecOptions(worktreePath, options) }) alreadySet = true } catch (readError) { @@ -676,7 +717,7 @@ export async function addWorktree( } if (!alreadySet) { await gitExecFileAsync(['config', '--local', 'push.autoSetupRemote', 'true'], { - cwd: worktreePath + ...gitExecOptions(worktreePath, options) }) } } catch (error) { @@ -710,23 +751,30 @@ export async function addSparseWorktree( options ) created = true - await gitExecFileAsync(['sparse-checkout', 'init', '--cone'], { cwd: worktreePath }) - await gitExecFileAsync(['sparse-checkout', 'set', '--', ...directories], { cwd: worktreePath }) - await gitExecFileAsync(['checkout', branch], { cwd: worktreePath }) + await gitExecFileAsync( + ['sparse-checkout', 'init', '--cone'], + gitExecOptions(worktreePath, options) + ) + await gitExecFileAsync( + ['sparse-checkout', 'set', '--', ...directories], + gitExecOptions(worktreePath, options) + ) + await gitExecFileAsync(['checkout', branch], gitExecOptions(worktreePath, options)) return addResult } catch (error) { const wrapped: SparseWorktreeCreateError = error instanceof Error ? (error as SparseWorktreeCreateError) : new Error(String(error)) if (created) { if (!options.checkoutExistingBranch) { - await unsetWorktreeCreationBase(worktreePath, branch) + await unsetWorktreeCreationBase(worktreePath, branch, options) } try { await removeWorktree(repoPath, worktreePath, true, { deleteBranch: !options.checkoutExistingBranch, // Why: rolling back a failed creation — the just-created branch has no // user commits, so force-delete it rather than preserving an orphan. - forceBranchDelete: !options.checkoutExistingBranch + forceBranchDelete: !options.checkoutExistingBranch, + ...(options.wslDistro ? { wslDistro: options.wslDistro } : {}) }) } catch { wrapped.cleanupFailed = true @@ -771,7 +819,7 @@ export async function removeWorktree( ): Promise { const removedWorktree = options.knownRemovedWorktree ?? - (await listWorktrees(repoPath)).find((worktree) => + (await listWorktrees(repoPath, options)).find((worktree) => areWorktreePathsEqual(worktree.path, worktreePath) ) const branchName = normalizeLocalBranchRef(removedWorktree?.branch ?? '') @@ -782,7 +830,7 @@ export async function removeWorktree( args.push('--force') } args.push(worktreePath) - await gitExecFileAsync(args, { cwd: repoPath }) + await gitExecFileAsync(args, gitExecOptions(repoPath, options)) if (!branchName) { return {} @@ -802,7 +850,8 @@ export async function removeWorktree( const branchDeleteResult = await deleteLocalBranchAfterWorktreeRemoval( repoPath, branchName, - options.forceBranchDelete === true + options.forceBranchDelete === true, + options ) if (branchDeleteResult === 'checked-out') { return {} @@ -812,7 +861,12 @@ export async function removeWorktree( if (!options.forceBranchDelete && branchHead) { try { if ( - await deleteAlreadyMergedBranchAfterSafeDeleteFailure(repoPath, branchName, branchHead) + await deleteAlreadyMergedBranchAfterSafeDeleteFailure( + repoPath, + branchName, + branchHead, + options + ) ) { return {} } @@ -838,11 +892,15 @@ export async function removeWorktree( async function deleteLocalBranchAfterWorktreeRemoval( repoPath: string, branchName: string, - forceBranchDelete: boolean + forceBranchDelete: boolean, + options: GitWorktreeExecOptions = {} ): Promise<'deleted' | 'checked-out'> { const deleteFlag = forceBranchDelete ? '-D' : '-d' try { - await gitExecFileAsync(['branch', deleteFlag, '--', branchName], { cwd: repoPath }) + await gitExecFileAsync( + ['branch', deleteFlag, '--', branchName], + gitExecOptions(repoPath, options) + ) return 'deleted' } catch (error) { if (!isBranchCheckedOutInWorktreeError(error)) { @@ -853,14 +911,17 @@ async function deleteLocalBranchAfterWorktreeRemoval( try { // Why: `branch -d` is the cheap live-checkout guard. Only pay for // `worktree prune` when a stale admin record may be the thing blocking it. - await gitExecFileAsync(['worktree', 'prune'], { cwd: repoPath }) + await gitExecFileAsync(['worktree', 'prune'], gitExecOptions(repoPath, options)) } catch (error) { console.warn(`[git] Failed to prune worktrees before deleting branch "${branchName}"`, error) return 'checked-out' } try { - await gitExecFileAsync(['branch', deleteFlag, '--', branchName], { cwd: repoPath }) + await gitExecFileAsync( + ['branch', deleteFlag, '--', branchName], + gitExecOptions(repoPath, options) + ) return 'deleted' } catch (error) { if (isBranchCheckedOutInWorktreeError(error)) { @@ -873,9 +934,10 @@ async function deleteLocalBranchAfterWorktreeRemoval( async function deleteAlreadyMergedBranchAfterSafeDeleteFailure( repoPath: string, branchName: string, - branchHead: string + branchHead: string, + options: GitWorktreeExecOptions = {} ): Promise { - const runGit = (args: string[]) => gitExecFileAsync(args, { cwd: repoPath }) + const runGit = (args: string[]) => gitExecFileAsync(args, gitExecOptions(repoPath, options)) const targetRefs = await getBranchCleanupTargetRefs(runGit, branchName) await refreshBranchCleanupTargetRefs(runGit, targetRefs) // Why: squash merges rewrite commit IDs, so `branch -d` can reject a branch @@ -884,7 +946,9 @@ async function deleteAlreadyMergedBranchAfterSafeDeleteFailure( if (!(await branchHasNoUnmergedChangesOnAnyTarget(runGit, branchName, targetRefs))) { return false } - await forceDeleteLocalBranch(repoPath, branchName, branchHead) + await forceDeleteLocalBranch(repoPath, branchName, branchHead, (args, cwd) => + gitExecFileAsync(args, gitExecOptions(cwd, options)) + ) return true } @@ -952,14 +1016,15 @@ async function isLocalBranchCheckedOut( */ export async function assertWorktreeCleanForRemoval( worktreePath: string, - force = false + force = false, + options: GitWorktreeExecOptions = {} ): Promise { if (force) { return } const { stdout } = await gitExecFileAsync(['status', '--porcelain', '--untracked-files=all'], { - cwd: worktreePath + ...gitExecOptions(worktreePath, options) }) if (!stdout.trim()) { return @@ -970,9 +1035,13 @@ export async function assertWorktreeCleanForRemoval( throw error } -function translateWorktreePath(worktreePath: string, repoPath: string): string { +function translateWorktreePath( + worktreePath: string, + repoPath: string, + options: GitWorktreeExecOptions = {} +): string { const prefix = 'worktree ' - const translated = translateWslOutputPaths(`${prefix}${worktreePath}`, repoPath) + const translated = translateWslOutputPaths(`${prefix}${worktreePath}`, repoPath, options) return translated.startsWith(prefix) ? translated.slice(prefix.length) : worktreePath } diff --git a/src/main/gitea/client.ts b/src/main/gitea/client.ts index 5aadd803bf8..8691a7b687b 100644 --- a/src/main/gitea/client.ts +++ b/src/main/gitea/client.ts @@ -6,6 +6,10 @@ import { type RawGiteaPullRequest } from './pull-request-mappers' import { getGiteaRepoRef, type GiteaRepoRef } from './repository-ref' +import { + getHostedReviewLocalGitOptions, + type HostedReviewExecutionOptions +} from '../source-control/hosted-review-git-options' const REQUEST_TIMEOUT_MS = 5000 const PULL_REQUEST_PAGE_LIMIT = 50 @@ -187,9 +191,14 @@ export async function getGiteaAuthStatus(): Promise { export async function getGiteaPullRequest( repoPath: string, prNumber: number, - connectionId?: string | null + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} ): Promise { - const repo = await getGiteaRepoRef(repoPath, connectionId) + const repo = await getGiteaRepoRef( + repoPath, + connectionId, + getHostedReviewLocalGitOptions(options) + ) if (!repo) { return null } @@ -204,14 +213,19 @@ export async function getGiteaPullRequestForBranch( repoPath: string, branch: string, linkedPRNumber?: number | null, - connectionId?: string | null + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} ): Promise { const branchName = branch.replace(/^refs\/heads\//, '') if (!branchName && linkedPRNumber == null) { return null } - const repo = await getGiteaRepoRef(repoPath, connectionId) + const repo = await getGiteaRepoRef( + repoPath, + connectionId, + getHostedReviewLocalGitOptions(options) + ) if (!repo) { return null } @@ -252,7 +266,8 @@ export async function getGiteaPullRequestForBranch( export async function getGiteaRepoSlug( repoPath: string, - connectionId?: string | null + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} ): Promise { - return getGiteaRepoRef(repoPath, connectionId) + return getGiteaRepoRef(repoPath, connectionId, getHostedReviewLocalGitOptions(options)) } diff --git a/src/main/gitea/repository-ref.test.ts b/src/main/gitea/repository-ref.test.ts index a104208d1b6..3964d3fdaad 100644 --- a/src/main/gitea/repository-ref.test.ts +++ b/src/main/gitea/repository-ref.test.ts @@ -125,6 +125,40 @@ describe('Gitea repository ref parsing', () => { }) }) + it('keeps local host and local WSL repository-ref cache entries separate', async () => { + gitExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: 'https://git.example.com/host/project.git\n', + stderr: '' + }) + .mockResolvedValueOnce({ + stdout: 'https://git.example.com/wsl/project.git\n', + stderr: '' + }) + + await expect(getGiteaRepoRef('/repo')).resolves.toMatchObject({ + owner: 'host', + repo: 'project' + }) + await expect(getGiteaRepoRef('/repo', null, { wslDistro: 'Ubuntu' })).resolves.toMatchObject({ + owner: 'wsl', + repo: 'project' + }) + await expect(getGiteaRepoRef('/repo', null, { wslDistro: 'Ubuntu' })).resolves.toMatchObject({ + owner: 'wsl', + repo: 'project' + }) + + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) + expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(1, ['remote', 'get-url', 'origin'], { + cwd: '/repo' + }) + expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(2, ['remote', 'get-url', 'origin'], { + cwd: '/repo', + wslDistro: 'Ubuntu' + }) + }) + it('bounds cached repository refs for distinct repo paths', async () => { gitExecFileAsyncMock.mockResolvedValue({ stdout: 'https://git.example.com/team/project.git\n', diff --git a/src/main/gitea/repository-ref.ts b/src/main/gitea/repository-ref.ts index 08dfd7108b6..f5a9acc8e5f 100644 --- a/src/main/gitea/repository-ref.ts +++ b/src/main/gitea/repository-ref.ts @@ -9,6 +9,10 @@ export type GiteaRepoRef = { webBaseUrl: string } +type LocalGitExecOptions = { + wslDistro?: string +} + const KNOWN_NON_GITEA_HOSTS = new Set([ 'github.com', 'gitlab.com', @@ -140,9 +144,11 @@ export function parseGiteaRepoRef(remoteUrl: string): GiteaRepoRef | null { export async function getGiteaRepoRefForRemote( repoPath: string, remoteName: string, - connectionId?: string | null + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} ): Promise { - const cacheKey = `${connectionId ?? 'local'}\0${repoPath}\0${remoteName}` + const runtimeKey = connectionId ?? `local:${localGitOptions.wslDistro ?? 'host'}` + const cacheKey = `${runtimeKey}\0${repoPath}\0${remoteName}` if (repoRefCache.has(cacheKey)) { return repoRefCache.get(cacheKey)! } @@ -154,7 +160,8 @@ export async function getGiteaRepoRefForRemote( const { stdout } = sshGitProvider ? await sshGitProvider.exec(['remote', 'get-url', remoteName], repoPath) : await gitExecFileAsync(['remote', 'get-url', remoteName], { - cwd: repoPath + cwd: repoPath, + ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) }) const result = parseGiteaRepoRef(stdout) rememberRepoRefCacheEntry(cacheKey, result) @@ -172,7 +179,8 @@ export async function getGiteaRepoRefForRemote( export async function getGiteaRepoRef( repoPath: string, - connectionId?: string | null + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} ): Promise { - return getGiteaRepoRefForRemote(repoPath, 'origin', connectionId) + return getGiteaRepoRefForRemote(repoPath, 'origin', connectionId, localGitOptions) } diff --git a/src/main/github/client-create-pr.test.ts b/src/main/github/client-create-pr.test.ts index 8051163c10c..22a9d011bfd 100644 --- a/src/main/github/client-create-pr.test.ts +++ b/src/main/github/client-create-pr.test.ts @@ -117,6 +117,42 @@ describe('createGitHubPullRequest', () => { expect(releaseMock).toHaveBeenCalledOnce() }) + it('runs local WSL project pull request creation through the selected distro', async () => { + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 43, + url: 'https://github.com/acme/widgets/pull/43' + }) + }) + + await expect( + createGitHubPullRequest( + '/repo-root', + { + provider: 'github', + base: 'main', + head: 'feature/wsl-create-pr', + title: 'WSL Create PR' + }, + null, + { localGitExecOptions: { wslDistro: 'Ubuntu' } } + ) + ).resolves.toEqual({ + ok: true, + number: 43, + url: 'https://github.com/acme/widgets/pull/43' + }) + + const [, options] = ghExecFileAsyncMock.mock.calls[0] + expect(options).toMatchObject({ + cwd: '/repo-root', + wslDistro: 'Ubuntu', + timeout: 60_000, + idempotent: false + }) + }) + it('creates SSH-backed pull requests without using the remote path as a local cwd', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock.mockResolvedValueOnce({ diff --git a/src/main/github/client-file-viewed.test.ts b/src/main/github/client-file-viewed.test.ts index 7b0ad95e823..c4345ffb6b6 100644 --- a/src/main/github/client-file-viewed.test.ts +++ b/src/main/github/client-file-viewed.test.ts @@ -14,10 +14,15 @@ vi.mock('./gh-utils', () => ({ getIssueOwnerRepo: vi.fn(), getOwnerRepoForRemote: vi.fn(), resolveIssueSource: vi.fn(), - ghRepoExecOptions: vi.fn((context) => (context.connectionId ? {} : { cwd: context.repoPath })), - githubRepoContext: vi.fn((repoPath, connectionId) => ({ + ghRepoExecOptions: vi.fn((context) => + context.connectionId + ? {} + : { cwd: context.repoPath, ...(context.wslDistro ? { wslDistro: context.wslDistro } : {}) } + ), + githubRepoContext: vi.fn((repoPath, connectionId, localGitOptions) => ({ repoPath, - connectionId: connectionId ?? null + connectionId: connectionId ?? null, + ...localGitOptions })), classifyGhError: vi.fn(), classifyListIssuesError: vi.fn(), @@ -73,4 +78,24 @@ describe('setPRFileViewed', () => { const args = ghExecFileAsyncMock.mock.calls[0][0] expect(args.find((arg: string) => arg.startsWith('query='))).toContain('unmarkFileAsViewed') }) + + it('routes local WSL file-viewed mutations through the selected distro', async () => { + ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '{}' }) + + await expect( + setPRFileViewed({ + repoPath: '/repo-root', + connectionId: null, + localGitOptions: { wslDistro: 'Ubuntu' }, + pullRequestId: 'PR_kwDO123', + path: 'src/app.ts', + viewed: true + }) + ).resolves.toBe(true) + + expect(ghExecFileAsyncMock.mock.calls[0][1]).toEqual({ + cwd: '/repo-root', + wslDistro: 'Ubuntu' + }) + }) }) diff --git a/src/main/github/client-issue-source.test.ts b/src/main/github/client-issue-source.test.ts index f2ec208f8bd..2b4a039b36a 100644 --- a/src/main/github/client-issue-source.test.ts +++ b/src/main/github/client-issue-source.test.ts @@ -173,8 +173,13 @@ describe('GitHub issue source split', () => { await listWorkItems('/home/jinwoo/orca', 10, undefined, undefined, 'auto', 'openclaw-2') - expect(resolveIssueSourceMock).toHaveBeenCalledWith('/home/jinwoo/orca', 'auto', 'openclaw-2') - expect(getOwnerRepoMock).toHaveBeenCalledWith('/home/jinwoo/orca', 'openclaw-2') + expect(resolveIssueSourceMock).toHaveBeenCalledWith( + '/home/jinwoo/orca', + 'auto', + 'openclaw-2', + {} + ) + expect(getOwnerRepoMock).toHaveBeenCalledWith('/home/jinwoo/orca', 'openclaw-2', {}) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 1, [ @@ -512,7 +517,7 @@ describe('GitHub issue source split', () => { const result = await listWorkItems('/repo-root', 10, undefined, undefined, 'auto') - expect(resolveIssueSourceMock).toHaveBeenCalledWith('/repo-root', 'auto', undefined) + expect(resolveIssueSourceMock).toHaveBeenCalledWith('/repo-root', 'auto', undefined, {}) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 1, [ diff --git a/src/main/github/client-pr-checks.test.ts b/src/main/github/client-pr-checks.test.ts index 928da277645..6d27a4e1c38 100644 --- a/src/main/github/client-pr-checks.test.ts +++ b/src/main/github/client-pr-checks.test.ts @@ -38,11 +38,19 @@ const { vi.mock('./gh-utils', () => ({ execFileAsync: execFileAsyncMock, ghExecFileAsync: ghExecFileAsyncMock, - githubRepoContext: (repoPath: string, connectionId?: string | null) => ({ + githubRepoContext: ( + repoPath: string, + connectionId?: string | null, + localGitOptions: { wslDistro?: string } = {} + ) => ({ repoPath, - connectionId: connectionId ?? null + connectionId: connectionId ?? null, + ...localGitOptions + }), + ghRepoExecOptions: (context: { repoPath: string; wslDistro?: string }) => ({ + cwd: context.repoPath, + ...(context.wslDistro ? { wslDistro: context.wslDistro } : {}) }), - ghRepoExecOptions: (context: { repoPath: string }) => ({ cwd: context.repoPath }), getOwnerRepo: getOwnerRepoMock, getIssueOwnerRepo: getIssueOwnerRepoMock, extractExecError: extractExecErrorMock, @@ -231,6 +239,51 @@ describe('getPRChecks', () => { ) }) + it('routes local WSL check retrieval and reruns through the selected distro', async () => { + const localGitOptions = { wslDistro: 'Ubuntu' } + getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + check_runs: [ + { + name: 'build', + status: 'completed', + conclusion: 'success', + html_url: 'https://github.com/acme/widgets/actions/runs/66', + details_url: null + } + ] + }) + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify([ + { + name: 'lint', + state: 'FAIL', + link: 'https://github.com/acme/widgets/actions/runs/77/job/88' + } + ]) + }) + .mockResolvedValueOnce({ stdout: '' }) + + await getPRChecks('/repo-root', 42, 'head-oid', undefined, undefined, null, localGitOptions) + await rerunPRChecks('/repo-root', 42, { failedOnly: true }, null, localGitOptions) + + expect(getOwnerRepoMock).toHaveBeenCalledWith('/repo-root', null, localGitOptions) + expect(ghExecFileAsyncMock.mock.calls.every((call) => call[1]?.wslDistro === 'Ubuntu')).toBe( + true + ) + expect(ghExecFileAsyncMock).toHaveBeenLastCalledWith( + ['api', '-X', 'POST', 'repos/acme/widgets/actions/runs/77/rerun-failed-jobs'], + expect.objectContaining({ + cwd: '/repo-root', + wslDistro: 'Ubuntu', + env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }) + }) + ) + }) + it('uses explicit PR repo for check-runs and gh pr checks fallback', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'widgets' }) ghExecFileAsyncMock diff --git a/src/main/github/client-pr-local-runtime.test.ts b/src/main/github/client-pr-local-runtime.test.ts new file mode 100644 index 00000000000..be6e90ba880 --- /dev/null +++ b/src/main/github/client-pr-local-runtime.test.ts @@ -0,0 +1,241 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +type RateLimitGuardResult = + | { blocked: false } + | { blocked: true; remaining: number; limit: number; resetAt: number } + +const { + execFileAsyncMock, + ghExecFileAsyncMock, + getOwnerRepoMock, + getIssueOwnerRepoMock, + getOwnerRepoForRemoteMock, + resolvePRRepositoryCandidatesMock, + getRemoteUrlForRepoMock, + gitExecFileAsyncMock, + getRateLimitMock, + rateLimitGuardMock, + noteRateLimitSpendMock, + acquireMock, + releaseMock +} = vi.hoisted(() => ({ + execFileAsyncMock: vi.fn(), + ghExecFileAsyncMock: vi.fn(), + getOwnerRepoMock: vi.fn(), + getIssueOwnerRepoMock: vi.fn(), + getOwnerRepoForRemoteMock: vi.fn(), + resolvePRRepositoryCandidatesMock: vi.fn(), + getRemoteUrlForRepoMock: vi.fn(), + gitExecFileAsyncMock: vi.fn(), + getRateLimitMock: vi.fn(), + rateLimitGuardMock: vi.fn<() => RateLimitGuardResult>(() => ({ blocked: false })), + noteRateLimitSpendMock: vi.fn(), + acquireMock: vi.fn(), + releaseMock: vi.fn() +})) + +vi.mock('./gh-utils', () => ({ + execFileAsync: execFileAsyncMock, + ghExecFileAsync: ghExecFileAsyncMock, + gitExecFileAsync: gitExecFileAsyncMock, + extractExecError: (err: unknown) => ({ + stderr: err instanceof Error ? err.message : String(err), + stdout: '' + }), + acquire: acquireMock, + release: releaseMock, + getOwnerRepo: getOwnerRepoMock, + getIssueOwnerRepo: getIssueOwnerRepoMock, + getOwnerRepoForRemote: getOwnerRepoForRemoteMock, + resolvePRRepositoryCandidates: resolvePRRepositoryCandidatesMock, + resolveIssueSource: vi.fn(), + classifyGhError: (message: string) => ({ type: 'unknown', message }), + classifyListIssuesError: (message: string) => ({ type: 'unknown', message }), + ghRepoExecOptions: (context: { + repoPath: string + connectionId?: string | null + wslDistro?: string + }) => + context.connectionId + ? {} + : { cwd: context.repoPath, ...(context.wslDistro ? { wslDistro: context.wslDistro } : {}) }, + githubRepoContext: ( + repoPath: string, + connectionId?: string | null, + localGitOptions: { wslDistro?: string } = {} + ) => ({ + repoPath, + connectionId: connectionId ?? null, + ...localGitOptions + }), + getRemoteUrlForRepo: getRemoteUrlForRepoMock, + _resetOwnerRepoCache: vi.fn() +})) + +vi.mock('../git/runner', () => ({ + gitExecFileAsync: gitExecFileAsyncMock +})) + +vi.mock('../providers/ssh-git-dispatch', () => ({ + getSshGitProvider: vi.fn() +})) + +vi.mock('./conflict-summary', () => ({ + getPRConflictSummary: vi.fn() +})) + +vi.mock('./rate-limit', () => ({ + getRateLimit: getRateLimitMock, + rateLimitGuard: rateLimitGuardMock, + noteRateLimitSpend: noteRateLimitSpendMock +})) + +import { + addPRReviewComment, + addPRReviewCommentReply, + getPRComments, + mergePR, + removePRReviewers, + requestPRReviewers, + resolveReviewThread, + setPRAutoMerge, + updatePRDetails, + updatePRState, + updatePRTitle +} from './client' + +describe('GitHub PR local runtime routing', () => { + beforeEach(() => { + execFileAsyncMock.mockReset() + ghExecFileAsyncMock.mockReset() + getOwnerRepoMock.mockReset() + getIssueOwnerRepoMock.mockReset() + getOwnerRepoForRemoteMock.mockReset() + resolvePRRepositoryCandidatesMock.mockReset() + getRemoteUrlForRepoMock.mockReset() + gitExecFileAsyncMock.mockReset() + getRateLimitMock.mockReset() + getRateLimitMock.mockResolvedValue({ resources: {} }) + rateLimitGuardMock.mockReset() + rateLimitGuardMock.mockReturnValue({ blocked: false }) + noteRateLimitSpendMock.mockReset() + acquireMock.mockReset() + releaseMock.mockReset() + acquireMock.mockResolvedValue(undefined) + }) + + it('routes PR details and mutations through the selected WSL distro', async () => { + const localGitOptions = { wslDistro: 'Ubuntu' } + const prRepo = { owner: 'acme', repo: 'orca' } + getOwnerRepoMock.mockResolvedValue(prRepo) + ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { + const endpoint = args.find((arg) => arg.startsWith('repos/acme/orca/')) ?? '' + const query = args.find((arg) => arg.startsWith('query=')) ?? '' + + if (args[0] === 'pr' && args[1] === 'view') { + return { + stdout: JSON.stringify({ + number: 7, + title: 'PR', + state: 'OPEN', + url: 'https://github.com/acme/orca/pull/7', + statusCheckRollup: [], + updatedAt: '2026-04-01T00:00:00Z', + isDraft: false, + mergeable: 'MERGEABLE', + reviewDecision: 'APPROVED', + mergeStateStatus: 'CLEAN', + autoMergeRequest: null, + baseRefName: 'main', + baseRefOid: 'base-oid', + headRefOid: 'head-oid' + }) + } + } + if (query.includes('reviewThreads')) { + return { + stdout: JSON.stringify({ + data: { + repository: { + pullRequest: { + reviewThreads: { nodes: [] }, + comments: { nodes: [] } + } + } + } + }) + } + } + if (endpoint.endsWith('/issues/7/comments?per_page=100')) { + return { stdout: '[]' } + } + if (endpoint.endsWith('/pulls/7/reviews?per_page=100')) { + return { stdout: '[]' } + } + if (endpoint.endsWith('/pulls/7/comments/11/replies')) { + return { stdout: JSON.stringify({ id: 12, user: null, body: 'Reply' }) } + } + if (endpoint.endsWith('/pulls/7/comments')) { + return { stdout: JSON.stringify({ id: 13, user: null, body: 'Inline' }) } + } + return { stdout: '', stderr: '' } + }) + + await getPRComments('/repo-root', 7, { prRepo }, null, localGitOptions) + await expect( + resolveReviewThread('/repo-root', 'thread-1', true, null, localGitOptions) + ).resolves.toBe(true) + await expect( + addPRReviewCommentReply( + '/repo-root', + 7, + 11, + 'Reply', + 'thread-1', + 'src/app.ts', + 10, + null, + prRepo, + localGitOptions + ) + ).resolves.toMatchObject({ ok: true }) + await expect( + addPRReviewComment({ + repoPath: '/repo-root', + connectionId: null, + localGitOptions, + prNumber: 7, + body: 'Inline', + commitId: 'head-oid', + path: 'src/app.ts', + line: 10 + }) + ).resolves.toMatchObject({ ok: true }) + await expect( + updatePRTitle('/repo-root', 7, 'New title', null, prRepo, localGitOptions) + ).resolves.toBe(true) + await expect( + updatePRDetails('/repo-root', 7, { body: 'New body' }, null, prRepo, localGitOptions) + ).resolves.toEqual({ ok: true }) + await expect( + updatePRState('/repo-root', 7, { state: 'closed' }, null, localGitOptions) + ).resolves.toEqual({ ok: true }) + await expect( + requestPRReviewers('/repo-root', 7, ['octo'], null, localGitOptions) + ).resolves.toEqual({ ok: true }) + await expect( + removePRReviewers('/repo-root', 7, ['octo'], null, localGitOptions) + ).resolves.toEqual({ ok: true }) + await expect( + setPRAutoMerge('/repo-root', 7, true, 'squash', null, prRepo, localGitOptions) + ).resolves.toEqual({ ok: true }) + await expect( + mergePR('/repo-root', 7, 'squash', null, prRepo, localGitOptions) + ).resolves.toEqual({ ok: true }) + + expect(getOwnerRepoMock).toHaveBeenCalledWith('/repo-root', null, localGitOptions) + expect(ghExecFileAsyncMock.mock.calls.every((call) => call[1]?.wslDistro === 'Ubuntu')).toBe( + true + ) + }) +}) diff --git a/src/main/github/client-work-items.test.ts b/src/main/github/client-work-items.test.ts index fd41de53ff0..8ffbc2d23a5 100644 --- a/src/main/github/client-work-items.test.ts +++ b/src/main/github/client-work-items.test.ts @@ -32,12 +32,23 @@ const { vi.mock('./gh-utils', () => ({ execFileAsync: execFileAsyncMock, ghExecFileAsync: ghExecFileAsyncMock, - githubRepoContext: (repoPath: string, connectionId?: string | null) => ({ + githubRepoContext: ( + repoPath: string, + connectionId?: string | null, + localGitOptions: { wslDistro?: string } = {} + ) => ({ repoPath, - connectionId: connectionId ?? null + connectionId: connectionId ?? null, + ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) }), - ghRepoExecOptions: (context: { repoPath: string; connectionId?: string | null }) => - context.connectionId ? {} : { cwd: context.repoPath }, + ghRepoExecOptions: (context: { + repoPath: string + connectionId?: string | null + wslDistro?: string + }) => + context.connectionId + ? {} + : { cwd: context.repoPath, ...(context.wslDistro ? { wslDistro: context.wslDistro } : {}) }, getOwnerRepo: getOwnerRepoMock, getIssueOwnerRepo: getIssueOwnerRepoMock, getOwnerRepoForRemote: getOwnerRepoForRemoteMock, @@ -227,6 +238,45 @@ describe('listWorkItems', () => { ]) }) + it('routes local WSL work-item listing through repo resolution and gh execution options', async () => { + const localGitOptions = { wslDistro: 'Ubuntu' } + resolveIssueSourceMock.mockResolvedValue({ + source: { owner: 'acme', repo: 'widgets' }, + fellBack: false + }) + getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + getOwnerRepoForRemoteMock.mockResolvedValue(null) + ghExecFileAsyncMock.mockResolvedValue({ stdout: '[]' }) + + await listWorkItems( + '/repo-root', + 5, + undefined, + undefined, + undefined, + null, + false, + localGitOptions + ) + + expect(resolveIssueSourceMock).toHaveBeenCalledWith( + '/repo-root', + undefined, + null, + localGitOptions + ) + expect(getOwnerRepoMock).toHaveBeenCalledWith('/repo-root', null, localGitOptions) + expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith( + '/repo-root', + 'upstream', + null, + localGitOptions + ) + expect(ghExecFileAsyncMock.mock.calls.every((call) => call[1]?.wslDistro === 'Ubuntu')).toBe( + true + ) + }) + it('hydrates PR list rows with repository merge metadata', async () => { getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) diff --git a/src/main/github/client.test.ts b/src/main/github/client.test.ts index 65511ede36b..9347a93c96f 100644 --- a/src/main/github/client.test.ts +++ b/src/main/github/client.test.ts @@ -35,11 +35,17 @@ const { rateLimitGuardMock: vi.fn<() => RateLimitGuardResult>(() => ({ blocked: false })), noteRateLimitSpendMock: vi.fn(), ghRepoExecOptionsMock: vi.fn((context) => - context.connectionId ? {} : { cwd: context.repoPath } + context.connectionId + ? {} + : { + cwd: context.repoPath, + ...(context.wslDistro ? { wslDistro: context.wslDistro } : {}) + } ), - githubRepoContextMock: vi.fn((repoPath, connectionId) => ({ + githubRepoContextMock: vi.fn((repoPath, connectionId, localGitOptions) => ({ repoPath, - connectionId: connectionId ?? null + connectionId: connectionId ?? null, + ...localGitOptions })), getSshGitProviderMock: vi.fn(), acquireMock: vi.fn(), @@ -1218,6 +1224,73 @@ describe('getPRForBranch', () => { }) }) + it('routes local WSL branch status and conflict summary git probes through the selected distro', async () => { + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify([ + { + number: 42, + title: 'Fix PR discovery', + state: 'open', + html_url: 'https://github.com/acme/widgets/pull/42', + updated_at: '2026-06-16T00:00:00Z', + draft: false, + mergeable_state: 'dirty', + base: { ref: 'main', sha: 'base-oid' }, + head: { ref: 'feature/test', sha: 'head-oid' } + } + ]) + }) + gitExecFileAsyncMock + .mockResolvedValueOnce({ stdout: '' }) + .mockResolvedValueOnce({ stdout: 'latest-base-oid\n' }) + .mockResolvedValueOnce({ stdout: 'merge-base-oid\n' }) + .mockResolvedValueOnce({ stdout: '2\n' }) + .mockResolvedValueOnce({ stdout: 'result-tree-oid\u0000src/conflict.ts\u0000' }) + + const pr = await getPRForBranch('/repo-root', 'feature/test', null, null, null, { + localGitExecOptions: { wslDistro: 'Ubuntu' } + }) + + expect(pr?.conflictSummary?.files).toEqual(['src/conflict.ts']) + expect(resolvePRRepositoryCandidatesMock).toHaveBeenCalledWith('/repo-root', null, { + wslDistro: 'Ubuntu' + }) + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + expect.any(Array), + expect.objectContaining({ + cwd: '/repo-root', + wslDistro: 'Ubuntu' + }) + ) + expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith( + 1, + ['fetch', '--quiet', 'origin', 'main'], + { + cwd: '/repo-root', + timeout: 10_000, + wslDistro: 'Ubuntu' + } + ) + expect(gitExecFileAsyncMock).toHaveBeenLastCalledWith( + [ + 'merge-tree', + '--write-tree', + '--name-only', + '-z', + '--no-messages', + '--merge-base', + 'merge-base-oid', + 'head-oid', + 'latest-base-oid' + ], + { + cwd: '/repo-root', + wslDistro: 'Ubuntu' + } + ) + }) + it('treats GitHub DIRTY merge state as conflicting when mergeable is still unknown', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock diff --git a/src/main/github/client.ts b/src/main/github/client.ts index fddbde6e10c..b67e6a1acf1 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -57,9 +57,15 @@ import { ghRepoExecOptions, githubRepoContext, getRemoteUrlForRepo, + type LocalGitExecOptions, type OwnerRepo } from './gh-utils' import { getSshGitProvider } from '../providers/ssh-git-dispatch' +import { + hasHostedReviewLocalGitOptions, + getHostedReviewLocalGitOptions, + type HostedReviewExecutionOptions +} from '../source-control/hosted-review-git-options' export { _resetOwnerRepoCache } from './gh-utils' export { getIssue, @@ -87,6 +93,7 @@ import { } from './rate-limit' type GhExecOptions = ReturnType +type HostedReviewLocalGitOptions = ReturnType const ORCA_REPO = 'stablyai/orca' const PR_CHECK_LOG_TAIL_LINES = 200 @@ -97,6 +104,12 @@ const PR_CHECK_LOG_TAIL_JOB_LIMIT = 5 const PR_CHECK_LOG_TAIL_CACHE_MAX_ENTRIES = 128 const prCheckLogTailCache = new Map() +function hostedReviewLocalGitOptionArgs( + options: HostedReviewExecutionOptions = {} +): [] | [HostedReviewLocalGitOptions] { + return hasHostedReviewLocalGitOptions(options) ? [getHostedReviewLocalGitOptions(options)] : [] +} + function setPrCheckLogTailCache(cacheKey: string, logTail: string | null): void { prCheckLogTailCache.set(cacheKey, logTail) while (prCheckLogTailCache.size > PR_CHECK_LOG_TAIL_CACHE_MAX_ENTRIES) { @@ -273,11 +286,16 @@ export type PullRequestPushTarget = { export async function getPullRequestPushTarget( repoPath: string, prNumber: number, - connectionId?: string | null + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} ): Promise { - const context = githubRepoContext(repoPath, connectionId) + const context = githubRepoContext(repoPath, connectionId, localGitOptions) const ghOptions = ghRepoExecOptions(context) - const { candidates } = await resolvePRRepositoryCandidates(repoPath, connectionId) + const { candidates } = await resolvePRRepositoryCandidates( + repoPath, + connectionId, + localGitOptions + ) if (candidates.length === 0) { return null } @@ -308,7 +326,7 @@ export async function getPullRequestPushTarget( if (!prStdout) { return null } - const origin = await getOwnerRepoForRemote(repoPath, 'origin', connectionId) + const origin = await getOwnerRepoForRemote(repoPath, 'origin', connectionId, localGitOptions) const pr = JSON.parse(prStdout) as { maintainer_can_modify?: boolean head?: { @@ -346,9 +364,7 @@ export async function getPullRequestPushTarget( let originUrl: string | null = null try { - const rawOriginUrl = connectionId - ? await getRemoteUrlForRepo(context, 'origin') - : (await gitExecFileAsync(['remote', 'get-url', 'origin'], { cwd: repoPath })).stdout + const rawOriginUrl = await getRemoteUrlForRepo(context, 'origin') originUrl = rawOriginUrl?.trim() || null } catch { originUrl = null @@ -788,9 +804,10 @@ async function fetchIssueWorkItem( repoPath: string, ownerRepo: OwnerRepo | null, number: number, - connectionId?: string | null + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} ): Promise { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId)) + const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) if (ownerRepo) { const { stdout } = await ghExecFileAsync( ['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/issues/${number}`], @@ -814,9 +831,10 @@ async function fetchPullRequestWorkItem( repoPath: string, ownerRepo: OwnerRepo | null, number: number, - connectionId?: string | null + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} ): Promise { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId)) + const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) if (ownerRepo) { try { const { stdout } = await ghExecFileAsync( @@ -960,11 +978,12 @@ type ResolvedPrWorkItemSource = { async function resolvePrWorkItemSource( repoPath: string, preference: IssueSourcePreference | undefined, - connectionId?: string | null + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} ): Promise { const [originCandidate, upstreamCandidate] = await Promise.all([ - getOwnerRepo(repoPath, connectionId), - getOwnerRepoForRemote(repoPath, 'upstream', connectionId) + getOwnerRepo(repoPath, connectionId, localGitOptions), + getOwnerRepoForRemote(repoPath, 'upstream', connectionId, localGitOptions) ]) const source = preference === 'upstream' ? (upstreamCandidate ?? originCandidate) : originCandidate @@ -977,9 +996,10 @@ async function listRecentWorkItems( prOwnerRepo: OwnerRepo | null, limit: number, connectionId?: string | null, - noCache?: boolean + noCache?: boolean, + localGitOptions: LocalGitExecOptions = {} ): Promise { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId)) + const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) const requiresExplicitRepo = Boolean(connectionId) const restCacheArgs = noCache ? [] : ['--cache', '120s'] assertSshRepoHasResolvedGitHubSource({ connectionId, issueOwnerRepo, prOwnerRepo }) @@ -1142,9 +1162,10 @@ async function listQueriedWorkItems( query: ParsedTaskQuery, limit: number, before?: string, - connectionId?: string | null + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} ): Promise { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId)) + const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) const requiresExplicitRepo = Boolean(connectionId) assertSshRepoHasResolvedGitHubSource({ connectionId, issueOwnerRepo, prOwnerRepo }) const hasPrOnlyFilter = @@ -1227,11 +1248,12 @@ export async function listWorkItems( before?: string, preference?: IssueSourcePreference, connectionId?: string | null, - noCache?: boolean + noCache?: boolean, + localGitOptions: LocalGitExecOptions = {} ): Promise> { const [issueResolved, prResolved] = await Promise.all([ - resolveIssueSource(repoPath, preference, connectionId), - resolvePrWorkItemSource(repoPath, preference, connectionId) + resolveIssueSource(repoPath, preference, connectionId, localGitOptions), + resolvePrWorkItemSource(repoPath, preference, connectionId, localGitOptions) ]) const issueOwnerRepo = issueResolved.source const prOwnerRepo = prResolved.source @@ -1249,7 +1271,8 @@ export async function listWorkItems( prOwnerRepo, limit, connectionId, - noCache + noCache, + localGitOptions ) : await listQueriedWorkItems( repoPath, @@ -1258,7 +1281,8 @@ export async function listWorkItems( parseTaskQuery(trimmedQuery), limit, before, - connectionId + connectionId, + localGitOptions ) const errors = partial.issuesError ? { issues: partial.issuesError } : undefined @@ -1332,10 +1356,11 @@ async function countWorkItemsForQuery( repoPath: string, ownerRepo: OwnerRepo, query: ParsedTaskQuery, - connectionId?: string | null + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} ): Promise { const searchQ = buildSearchQueryString(ownerRepo, query) - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId)) + const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) const { stdout } = await ghExecFileAsync( [ 'api', @@ -1381,11 +1406,12 @@ export async function countWorkItems( repoPath: string, query?: string, preference?: IssueSourcePreference, - connectionId?: string | null + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} ): Promise { const [issueResolved, prResolved] = await Promise.all([ - resolveIssueSource(repoPath, preference, connectionId), - resolvePrWorkItemSource(repoPath, preference, connectionId) + resolveIssueSource(repoPath, preference, connectionId, localGitOptions), + resolvePrWorkItemSource(repoPath, preference, connectionId, localGitOptions) ]) const issueOwnerRepo = issueResolved.source const prOwnerRepo = prResolved.source @@ -1401,7 +1427,13 @@ export async function countWorkItems( await acquire() try { if (sameOwnerRepo(issueOwnerRepo, prOwnerRepo)) { - return await countWorkItemsForQuery(repoPath, ownerRepo, effectiveQuery, connectionId) + return await countWorkItemsForQuery( + repoPath, + ownerRepo, + effectiveQuery, + connectionId, + localGitOptions + ) } const counts: Promise[] = [] @@ -1423,7 +1455,8 @@ export async function countWorkItems( repoPath, issueOwnerRepo, { ...effectiveQuery, scope: 'issue' }, - connectionId + connectionId, + localGitOptions ) ) } @@ -1433,7 +1466,8 @@ export async function countWorkItems( repoPath, prOwnerRepo, { ...effectiveQuery, scope: 'pr' }, - connectionId + connectionId, + localGitOptions ) ) } @@ -1460,9 +1494,10 @@ export async function countWorkItems( export async function getRepoSlug( repoPath: string, - connectionId?: string | null + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} ): Promise<{ owner: string; repo: string } | null> { - return getOwnerRepo(repoPath, connectionId) + return getOwnerRepo(repoPath, connectionId, ...hostedReviewLocalGitOptionArgs(options)) } /** @@ -1475,13 +1510,21 @@ export async function getRepoSlug( */ export async function getRepoUpstream( repoPath: string, - connectionId?: string | null + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} ): Promise { - const origin = await getOwnerRepo(repoPath, connectionId) + const localGitArgs = hostedReviewLocalGitOptionArgs(options) + const localGitOptions = localGitArgs[0] ?? {} + const origin = await getOwnerRepo(repoPath, connectionId, ...localGitArgs) if (!origin) { return null } - const upstreamRemote = await getOwnerRepoForRemote(repoPath, 'upstream', connectionId) + const upstreamRemote = await getOwnerRepoForRemote( + repoPath, + 'upstream', + connectionId, + ...localGitArgs + ) if (upstreamRemote && !sameOwnerRepo(upstreamRemote, origin)) { return upstreamRemote } @@ -1491,7 +1534,10 @@ export async function getRepoUpstream( ['repo', 'view', `${origin.owner}/${origin.repo}`, '--json', 'isFork,parent'], // Why: best-effort fork lookup runs at add-time; cap latency so a stalled // gh process can't hold up repo creation. - { ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId)), timeout: 10_000 } + { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + timeout: 10_000 + } ) const data = JSON.parse(stdout) as { isFork?: boolean @@ -1585,6 +1631,7 @@ async function findOpenPRByHeadBase(args: { head: string base: string connectionId?: string | null + options?: HostedReviewExecutionOptions }): Promise<{ number: number; url: string } | null> { const context = githubRepoContext(args.repoPath, args.connectionId) const { stdout } = await ghExecFileAsync( @@ -1604,7 +1651,10 @@ async function findOpenPRByHeadBase(args: { '--json', 'number,url' ], - ghRepoExecOptions(context) + { + ...ghRepoExecOptions(context), + ...(args.connectionId ? {} : getHostedReviewLocalGitOptions(args.options)) + } ) const list = JSON.parse(stdout) as { number?: number; url?: string }[] if (list.length !== 1 || !list[0]?.number || !list[0]?.url) { @@ -1651,7 +1701,8 @@ async function readPullRequestTemplate( export async function createGitHubPullRequest( repoPath: string, input: CreateHostedReviewInput, - connectionId?: string | null + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} ): Promise { if (input.provider !== 'github') { return { @@ -1661,7 +1712,11 @@ export async function createGitHubPullRequest( } } - const ownerRepo = await getOwnerRepo(repoPath, connectionId) + const ownerRepo = await getOwnerRepo( + repoPath, + connectionId, + ...hostedReviewLocalGitOptionArgs(options) + ) if (!ownerRepo) { return { ok: false, @@ -1719,6 +1774,7 @@ export async function createGitHubPullRequest( const context = githubRepoContext(repoPath, connectionId) const { stdout } = await ghExecFileAsync(createArgs, { ...ghRepoExecOptions(context), + ...(connectionId ? {} : getHostedReviewLocalGitOptions(options)), timeout: 60_000, idempotent: false }) @@ -1727,9 +1783,14 @@ export async function createGitHubPullRequest( return { ok: true, ...created } } const found = head - ? await findOpenPRByHeadBase({ repoPath, ownerRepo, head, base, connectionId }).catch( - () => null - ) + ? await findOpenPRByHeadBase({ + repoPath, + ownerRepo, + head, + base, + connectionId, + options + }).catch(() => null) : null if (found) { return { ok: true, ...found } @@ -1751,7 +1812,8 @@ export async function createGitHubPullRequest( ownerRepo, head, base, - connectionId + connectionId, + options }).catch(() => null) if (existing) { return { @@ -1774,33 +1836,37 @@ export async function getWorkItem( repoPath: string, number: number, type?: 'issue' | 'pr', - connectionId?: string | null + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} ): Promise { await acquire() try { if (type === 'issue') { return await fetchIssueWorkItem( repoPath, - await getIssueOwnerRepo(repoPath, connectionId), + await getIssueOwnerRepo(repoPath, connectionId, localGitOptions), number, - connectionId + connectionId, + localGitOptions ) } if (type === 'pr') { return await fetchPullRequestWorkItem( repoPath, - await getOwnerRepo(repoPath, connectionId), + await getOwnerRepo(repoPath, connectionId, localGitOptions), number, - connectionId + connectionId, + localGitOptions ) } try { const issue = await fetchIssueWorkItem( repoPath, - await getIssueOwnerRepo(repoPath, connectionId), + await getIssueOwnerRepo(repoPath, connectionId, localGitOptions), number, - connectionId + connectionId, + localGitOptions ) if (issue) { return issue @@ -1819,9 +1885,10 @@ export async function getWorkItem( } return await fetchPullRequestWorkItem( repoPath, - await getOwnerRepo(repoPath, connectionId), + await getOwnerRepo(repoPath, connectionId, localGitOptions), number, - connectionId + connectionId, + localGitOptions ) } catch { return null @@ -1835,14 +1902,21 @@ export async function getWorkItemByOwnerRepo( ownerRepo: OwnerRepo, number: number, type: 'issue' | 'pr', - connectionId?: string | null + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} ): Promise { await acquire() try { if (type === 'issue') { - return await fetchIssueWorkItem(repoPath, ownerRepo, number, connectionId) + return await fetchIssueWorkItem(repoPath, ownerRepo, number, connectionId, localGitOptions) } - return await fetchPullRequestWorkItem(repoPath, ownerRepo, number, connectionId) + return await fetchPullRequestWorkItem( + repoPath, + ownerRepo, + number, + connectionId, + localGitOptions + ) } catch { return null } finally { @@ -2154,14 +2228,18 @@ function parseTrackedUpstreamBranch( async function getTrackedUpstreamBranch( repoPath: string, branchName: string, - connectionId?: string | null + connectionId?: string | null, + localGitOptions: { wslDistro?: string } = {} ): Promise { const args = ['rev-parse', '--abbrev-ref', '--symbolic-full-name', `${branchName}@{upstream}`] try { const provider = connectionId ? getSshGitProvider(connectionId) : null const result = provider ? await provider.exec(args, repoPath) - : await gitExecFileAsync(args, { cwd: repoPath }) + : await gitExecFileAsync(args, { + cwd: repoPath, + ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) + }) return parseTrackedUpstreamBranch(result.stdout, branchName) } catch { return null @@ -2353,14 +2431,16 @@ export async function getPRForBranch( branch: string, linkedPRNumber?: number | null, connectionId?: string | null, - fallbackPRNumber?: number | null + fallbackPRNumber?: number | null, + options: HostedReviewExecutionOptions = {} ): Promise { const outcome = await getPRForBranchOutcome( repoPath, branch, linkedPRNumber, connectionId, - fallbackPRNumber + fallbackPRNumber, + options ) return outcome.kind === 'found' ? outcome.pr : null } @@ -2370,7 +2450,8 @@ export async function getPRForBranchOutcome( branch: string, linkedPRNumber?: number | null, connectionId?: string | null, - fallbackPRNumber?: number | null + fallbackPRNumber?: number | null, + options: HostedReviewExecutionOptions = {} ): Promise { // Strip refs/heads/ prefix if present const branchName = branch.replace(/^refs\/heads\//, '') @@ -2379,12 +2460,18 @@ export async function getPRForBranchOutcome( if (!branchName && typeof linkedPRNumber !== 'number' && typeof fallbackPRNumber !== 'number') { return { kind: 'no-pr', fetchedAt: Date.now() } } - const context = githubRepoContext(repoPath, connectionId) + const localGitArgs = hostedReviewLocalGitOptionArgs(options) + const localGitOptions = localGitArgs[0] ?? {} + const context = githubRepoContext(repoPath, connectionId, localGitOptions) const ghOptions = ghRepoExecOptions(context) await acquire() try { - const { candidates, headRepo } = await resolvePRRepositoryCandidates(repoPath, connectionId) + const { candidates, headRepo } = await resolvePRRepositoryCandidates( + repoPath, + connectionId, + ...localGitArgs + ) let data: PullRequestLookupData | null = null let dataRepo: OwnerRepo | null = null let dataHeadRepo: OwnerRepo | null = headRepo @@ -2411,11 +2498,20 @@ export async function getPRForBranchOutcome( if (!data) { // Why: worktrees can have a short local branch tracking a differently // named remote PR head; after the local miss, try that configured head. - const upstreamBranch = await getTrackedUpstreamBranch(repoPath, branchName, connectionId) + const upstreamBranch = await getTrackedUpstreamBranch( + repoPath, + branchName, + connectionId, + localGitOptions + ) if (upstreamBranch) { const upstreamHeadRepo = - (await getOwnerRepoForRemote(repoPath, upstreamBranch.remoteName, connectionId)) ?? - headRepo + (await getOwnerRepoForRemote( + repoPath, + upstreamBranch.remoteName, + connectionId, + ...localGitArgs + )) ?? headRepo const upstreamLookup = await lookupPRByBranchName({ candidates, headRepo: upstreamHeadRepo, @@ -2458,7 +2554,13 @@ export async function getPRForBranchOutcome( data.baseRefName && data.baseRefOid && data.headRefOid - ? await getPRConflictSummary(repoPath, data.baseRefName, data.baseRefOid, data.headRefOid) + ? await getPRConflictSummary( + repoPath, + data.baseRefName, + data.baseRefOid, + data.headRefOid, + localGitOptions + ) : undefined return { @@ -2507,10 +2609,11 @@ export async function getPRChecks( headSha?: string, prRepo?: OwnerRepo | null, options?: { noCache?: boolean }, - connectionId?: string | null + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} ): Promise { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId)) - const ownerRepo = prRepo ?? (await getOwnerRepo(repoPath, connectionId)) + const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) + const ownerRepo = prRepo ?? (await getOwnerRepo(repoPath, connectionId, localGitOptions)) const fallbackToPRChecks = async (): Promise => { // Why: the REST check-runs path spends core only. Guard GraphQL only when // we actually fall back to `gh pr checks`, so a low GraphQL bucket does not @@ -2745,10 +2848,11 @@ export async function getPRCheckDetails( url?: string | null prRepo?: OwnerRepo | null }, - connectionId?: string | null + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} ): Promise { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId)) - const ownerRepo = args.prRepo ?? (await getOwnerRepo(repoPath, connectionId)) + const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) + const ownerRepo = args.prRepo ?? (await getOwnerRepo(repoPath, connectionId, localGitOptions)) if (!ownerRepo) { return null } @@ -2837,10 +2941,11 @@ export async function rerunPRChecks( repoPath: string, prNumber: number, options: { headSha?: string; failedOnly?: boolean } = {}, - connectionId?: string | null + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} ): Promise { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId)) - const ownerRepo = await getOwnerRepo(repoPath, connectionId) + const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) + const ownerRepo = await getOwnerRepo(repoPath, connectionId, localGitOptions) if (!ownerRepo) { return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } } @@ -2851,7 +2956,8 @@ export async function rerunPRChecks( options.headSha, ownerRepo, { noCache: true }, - connectionId + connectionId, + localGitOptions ) const candidates = options.failedOnly ? checks.filter((check) => @@ -2975,10 +3081,11 @@ export async function getPRComments( repoPath: string, prNumber: number, options?: { noCache?: boolean; prRepo?: OwnerRepo | null }, - connectionId?: string | null + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} ): Promise { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId)) - const ownerRepo = options?.prRepo ?? (await getOwnerRepo(repoPath, connectionId)) + const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) + const ownerRepo = options?.prRepo ?? (await getOwnerRepo(repoPath, connectionId, localGitOptions)) if (ownerRepo) { await assertRateLimitBudget('core') } @@ -3215,11 +3322,14 @@ export async function getPRComments( export async function setPRFileViewed(args: { repoPath: string connectionId?: string | null + localGitOptions?: LocalGitExecOptions pullRequestId: string path: string viewed: boolean }): Promise { - const ghOptions = ghRepoExecOptions(githubRepoContext(args.repoPath, args.connectionId)) + const ghOptions = ghRepoExecOptions( + githubRepoContext(args.repoPath, args.connectionId, args.localGitOptions) + ) const mutation = args.viewed ? 'markFileAsViewed' : 'unmarkFileAsViewed' const query = `mutation($pullRequestId: ID!, $path: String!) { ${mutation}(input: { pullRequestId: $pullRequestId, path: $path }) { @@ -3257,11 +3367,12 @@ export async function resolveReviewThread( repoPath: string, threadId: string, resolve: boolean, - connectionId?: string | null + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} ): Promise { const mutation = resolve ? 'resolveReviewThread' : 'unresolveReviewThread' const query = `mutation($threadId: ID!) { ${mutation}(input: { threadId: $threadId }) { thread { isResolved } } }` - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId)) + const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) const guard = rateLimitGuard('graphql') if (guard.blocked) { console.warn( @@ -3325,10 +3436,11 @@ export async function addPRReviewCommentReply( path?: string, line?: number, connectionId?: string | null, - prRepo?: OwnerRepo | null + prRepo?: OwnerRepo | null, + localGitOptions: LocalGitExecOptions = {} ): Promise { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId)) - const ownerRepo = prRepo ?? (await getOwnerRepo(repoPath, connectionId)) + const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) + const ownerRepo = prRepo ?? (await getOwnerRepo(repoPath, connectionId, localGitOptions)) if (!ownerRepo) { return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } } @@ -3362,10 +3474,15 @@ export async function addPRReviewCommentReply( } export async function addPRReviewComment( - args: GitHubPRReviewCommentInput & { connectionId?: string | null } + args: GitHubPRReviewCommentInput & { + connectionId?: string | null + localGitOptions?: LocalGitExecOptions + } ): Promise { - const ghOptions = ghRepoExecOptions(githubRepoContext(args.repoPath, args.connectionId)) - const ownerRepo = await getOwnerRepo(args.repoPath, args.connectionId) + const ghOptions = ghRepoExecOptions( + githubRepoContext(args.repoPath, args.connectionId, args.localGitOptions) + ) + const ownerRepo = await getOwnerRepo(args.repoPath, args.connectionId, args.localGitOptions) if (!ownerRepo) { return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } } @@ -3423,10 +3540,11 @@ export async function mergePR( prNumber: number, method: 'merge' | 'squash' | 'rebase' = 'squash', connectionId?: string | null, - prRepo?: OwnerRepo | null + prRepo?: OwnerRepo | null, + localGitOptions: LocalGitExecOptions = {} ): Promise<{ ok: true } | { ok: false; error: string }> { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId)) - const ownerRepo = prRepo ?? (await getOwnerRepo(repoPath, connectionId)) + const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) + const ownerRepo = prRepo ?? (await getOwnerRepo(repoPath, connectionId, localGitOptions)) await acquire() try { const mergeBlocker = await getPRMergeBlocker( @@ -3434,7 +3552,8 @@ export async function mergePR( prNumber, ownerRepo, ghOptions, - connectionId + connectionId, + localGitOptions ) if (mergeBlocker) { return { ok: false, error: mergeBlocker } @@ -3467,10 +3586,11 @@ export async function setPRAutoMerge( enabled: boolean, method: GitHubPRMergeMethod = 'squash', connectionId?: string | null, - prRepo?: OwnerRepo | null + prRepo?: OwnerRepo | null, + localGitOptions: LocalGitExecOptions = {} ): Promise<{ ok: true } | { ok: false; error: string }> { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId)) - const ownerRepo = prRepo ?? (await getOwnerRepo(repoPath, connectionId)) + const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) + const ownerRepo = prRepo ?? (await getOwnerRepo(repoPath, connectionId, localGitOptions)) await acquire() try { const args = ['pr', 'merge', String(prNumber), enabled ? '--auto' : '--disable-auto'] @@ -3499,7 +3619,8 @@ async function getPRMergeBlocker( prNumber: number, ownerRepo: OwnerRepo | null, ghOptions: GhExecOptions, - connectionId?: string | null + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} ): Promise { if (!ownerRepo) { return null @@ -3535,7 +3656,8 @@ async function getPRMergeBlocker( repoPath, pr.baseRefName, pr.baseRefOid, - pr.headRefOid + pr.headRefOid, + localGitOptions ) return formatMergeConflictBlocker(pr.baseRefName, summary) } catch { @@ -3563,11 +3685,12 @@ export async function updatePRState( repoPath: string, prNumber: number, updates: GitHubPullRequestStateUpdate, - connectionId?: string | null + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} ): Promise<{ ok: true } | { ok: false; error: string }> { - const context = githubRepoContext(repoPath, connectionId) + const context = githubRepoContext(repoPath, connectionId, localGitOptions) const ghOptions = ghRepoExecOptions(context) - const ownerRepo = await getOwnerRepo(repoPath, connectionId) + const ownerRepo = await getOwnerRepo(repoPath, connectionId, localGitOptions) if (!ownerRepo) { return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } } @@ -3597,14 +3720,15 @@ export async function requestPRReviewers( repoPath: string, prNumber: number, reviewers: string[], - connectionId?: string | null + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} ): Promise<{ ok: true } | { ok: false; error: string }> { const logins = reviewers.map((reviewer) => reviewer.trim()).filter(Boolean) if (logins.length === 0) { return { ok: false, error: 'Enter at least one reviewer' } } - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId)) - const ownerRepo = await getOwnerRepo(repoPath, connectionId) + const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) + const ownerRepo = await getOwnerRepo(repoPath, connectionId, localGitOptions) await acquire() try { const args = ['pr', 'edit', String(prNumber), '--add-reviewer', logins.join(',')] @@ -3629,14 +3753,15 @@ export async function removePRReviewers( repoPath: string, prNumber: number, reviewers: string[], - connectionId?: string | null + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} ): Promise<{ ok: true } | { ok: false; error: string }> { const logins = reviewers.map((reviewer) => reviewer.trim()).filter(Boolean) if (logins.length === 0) { return { ok: false, error: 'Enter at least one reviewer' } } - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId)) - const ownerRepo = await getOwnerRepo(repoPath, connectionId) + const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) + const ownerRepo = await getOwnerRepo(repoPath, connectionId, localGitOptions) await acquire() try { const args = ['pr', 'edit', String(prNumber), '--remove-reviewer', logins.join(',')] @@ -3665,10 +3790,11 @@ export async function updatePRTitle( prNumber: number, title: string, connectionId?: string | null, - prRepo?: OwnerRepo | null + prRepo?: OwnerRepo | null, + localGitOptions: LocalGitExecOptions = {} ): Promise { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId)) - const ownerRepo = prRepo ?? (await getOwnerRepo(repoPath, connectionId)) + const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) + const ownerRepo = prRepo ?? (await getOwnerRepo(repoPath, connectionId, localGitOptions)) await acquire() try { const args = ['pr', 'edit', String(prNumber), '--title', title] @@ -3692,10 +3818,11 @@ export async function updatePRDetails( prNumber: number, updates: { title?: string; body?: string }, connectionId?: string | null, - prRepo?: OwnerRepo | null + prRepo?: OwnerRepo | null, + localGitOptions: LocalGitExecOptions = {} ): Promise<{ ok: true } | { ok: false; error: string }> { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId)) - const ownerRepo = prRepo ?? (await getOwnerRepo(repoPath, connectionId)) + const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) + const ownerRepo = prRepo ?? (await getOwnerRepo(repoPath, connectionId, localGitOptions)) if (!ownerRepo) { return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } } diff --git a/src/main/github/conflict-summary.ts b/src/main/github/conflict-summary.ts index 2f78ff904d8..cf62d6e3cdc 100644 --- a/src/main/github/conflict-summary.ts +++ b/src/main/github/conflict-summary.ts @@ -1,11 +1,16 @@ import type { PRConflictSummary } from '../../shared/types' import { gitExecFileAsync } from '../git/runner' +type LocalGitExecOptions = { + wslDistro?: string +} + export async function getPRConflictSummary( repoPath: string, baseRefName: string, baseRefOid: string, - headRefOid: string + headRefOid: string, + localGitOptions: LocalGitExecOptions = {} ): Promise { try { // Why: the renderer only needs a read-only merge-conflict snapshot. We @@ -16,11 +21,16 @@ export async function getPRConflictSummary( // freshly-fetched remote-tracking ref so Orca matches GitHub's portal, // which compares against the latest base branch tip rather than the PR's // older pinned baseRefOid snapshot. - const latestBaseOid = await resolveLatestBaseOid(repoPath, baseRefName, baseRefOid) - const mergeBase = await resolveMergeBase(repoPath, headRefOid, latestBaseOid) + const latestBaseOid = await resolveLatestBaseOid( + repoPath, + baseRefName, + baseRefOid, + localGitOptions + ) + const mergeBase = await resolveMergeBase(repoPath, headRefOid, latestBaseOid, localGitOptions) const [commitsBehind, files] = await Promise.all([ - countCommits(repoPath, `${headRefOid}..${latestBaseOid}`), - loadConflictingFiles(repoPath, mergeBase, headRefOid, latestBaseOid) + countCommits(repoPath, `${headRefOid}..${latestBaseOid}`, localGitOptions), + loadConflictingFiles(repoPath, mergeBase, headRefOid, latestBaseOid, localGitOptions) ]) return { @@ -37,7 +47,8 @@ export async function getPRConflictSummary( async function resolveLatestBaseOid( repoPath: string, baseRefName: string, - fallbackBaseOid: string + fallbackBaseOid: string, + localGitOptions: LocalGitExecOptions ): Promise { const remoteName = 'origin' @@ -46,7 +57,8 @@ async function resolveLatestBaseOid( // the conflict-summary derivation indefinitely. await gitExecFileAsync(['fetch', '--quiet', remoteName, baseRefName], { cwd: repoPath, - timeout: 10_000 + timeout: 10_000, + ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) }) } catch { // Why: fetching the base ref keeps the conflict list aligned with GitHub's @@ -57,7 +69,8 @@ async function resolveLatestBaseOid( for (const ref of [`refs/remotes/${remoteName}/${baseRefName}`, `${remoteName}/${baseRefName}`]) { try { const { stdout } = await gitExecFileAsync(['rev-parse', '--verify', ref], { - cwd: repoPath + cwd: repoPath, + ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) }) const oid = stdout.trim() if (oid) { @@ -74,17 +87,24 @@ async function resolveLatestBaseOid( async function resolveMergeBase( repoPath: string, headOid: string, - baseOid: string + baseOid: string, + localGitOptions: LocalGitExecOptions ): Promise { const { stdout } = await gitExecFileAsync(['merge-base', headOid, baseOid], { - cwd: repoPath + cwd: repoPath, + ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) }) return stdout.trim() } -async function countCommits(repoPath: string, range: string): Promise { +async function countCommits( + repoPath: string, + range: string, + localGitOptions: LocalGitExecOptions +): Promise { const { stdout } = await gitExecFileAsync(['rev-list', '--count', range], { - cwd: repoPath + cwd: repoPath, + ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) }) return Number.parseInt(stdout.trim(), 10) || 0 } @@ -93,7 +113,8 @@ async function loadConflictingFiles( repoPath: string, mergeBase: string, headOid: string, - baseOid: string + baseOid: string, + localGitOptions: LocalGitExecOptions ): Promise { const modernArgs = [ 'merge-tree', @@ -117,7 +138,10 @@ async function loadConflictingFiles( ] try { - const result = await gitExecFileAsync(modernArgs, { cwd: repoPath }) + const result = await gitExecFileAsync(modernArgs, { + cwd: repoPath, + ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) + }) return parseMergeTreeNameOnlyOutput(result.stdout) } catch (error) { // Why: `git merge-tree --write-tree` exits with status 1 when it finds @@ -133,7 +157,10 @@ async function loadConflictingFiles( } try { - const result = await gitExecFileAsync(legacyArgs, { cwd: repoPath }) + const result = await gitExecFileAsync(legacyArgs, { + cwd: repoPath, + ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) + }) return parseMergeTreeNameOnlyOutput(result.stdout) } catch (fallbackError) { const fallbackStdout = getGitErrorOutput(fallbackError, 'stdout') diff --git a/src/main/github/gh-error-classification.ts b/src/main/github/gh-error-classification.ts new file mode 100644 index 00000000000..b7064ef0f6e --- /dev/null +++ b/src/main/github/gh-error-classification.ts @@ -0,0 +1,55 @@ +import type { ClassifiedError } from '../../shared/types' + +// Why: gh CLI surfaces API errors as unstructured stderr. Map known +// patterns to typed errors so callers can show user-friendly messages. +export function classifyGhError(stderr: string): ClassifiedError { + const s = stderr.toLowerCase() + if (s.includes('http 403') || s.includes('resource not accessible')) { + return { + type: 'permission_denied', + message: "You don't have permission to edit this issue. Check your GitHub token scopes." + } + } + if (s.includes('http 404') || s.includes('could not resolve to a repository')) { + return { type: 'not_found', message: 'Issue not found — it may have been deleted.' } + } + if (s.includes('has disabled issues')) { + return { type: 'issues_disabled', message: 'Issues are disabled on this repository.' } + } + if (s.includes('http 422') || s.includes('validation failed')) { + return { type: 'validation_error', message: `Invalid update — ${stderr.trim()}` } + } + if (s.includes('rate limit')) { + return { + type: 'rate_limited', + message: 'GitHub rate limit hit. Try again in a few minutes.' + } + } + if ( + s.includes('timeout') || + s.includes('no such host') || + s.includes('network') || + s.includes('could not resolve host') + ) { + return { type: 'network_error', message: 'Network error — check your connection.' } + } + return { type: 'unknown', message: `Failed to update issue: ${stderr.trim()}` } +} + +// Why: classifyGhError's copy is phrased for edit/update operations, but +// listIssues is a read op and renderer banners interpolate the message. +export function classifyListIssuesError(stderr: string): ClassifiedError { + const c = classifyGhError(stderr) + const trimmed = stderr.trim() + const readMessages: Record = { + permission_denied: + "You don't have permission to read issues for this repository. Check your GitHub token scopes.", + not_found: 'Repository not found.', + issues_disabled: 'Issues are disabled on this repository.', + validation_error: `Invalid request — ${trimmed}`, + rate_limited: 'GitHub rate limit hit. Try again in a few minutes.', + network_error: 'Network error — check your connection.', + unknown: `Failed to load issues: ${trimmed}` + } + return { type: c.type, message: readMessages[c.type] } +} diff --git a/src/main/github/gh-utils.test.ts b/src/main/github/gh-utils.test.ts index 61d3318f8aa..9fc4bfb3082 100644 --- a/src/main/github/gh-utils.test.ts +++ b/src/main/github/gh-utils.test.ts @@ -191,6 +191,31 @@ describe('github owner/repo resolution', () => { await expect(getOwnerRepo('/repo', 'ssh-1')).resolves.toEqual({ owner: 'remote', repo: 'orca' }) }) + it('keeps local host and local WSL owner/repo cache entries separate for the same path', async () => { + gitExecFileAsyncMock + .mockResolvedValueOnce({ stdout: 'git@github.com:host/orca.git\n' }) + .mockResolvedValueOnce({ stdout: 'git@github.com:wsl/orca.git\n' }) + + await expect(getOwnerRepo('/repo')).resolves.toEqual({ owner: 'host', repo: 'orca' }) + await expect(getOwnerRepo('/repo', null, { wslDistro: 'Ubuntu' })).resolves.toEqual({ + owner: 'wsl', + repo: 'orca' + }) + await expect(getOwnerRepo('/repo', null, { wslDistro: 'Ubuntu' })).resolves.toEqual({ + owner: 'wsl', + repo: 'orca' + }) + + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) + expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(1, ['remote', 'get-url', 'origin'], { + cwd: '/repo' + }) + expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(2, ['remote', 'get-url', 'origin'], { + cwd: '/repo', + wslDistro: 'Ubuntu' + }) + }) + it('prunes expired distinct owner/repo cache entries on later lookups', async () => { const nowSpy = vi.spyOn(Date, 'now') try { diff --git a/src/main/github/gh-utils.ts b/src/main/github/gh-utils.ts index 944436241dd..44f25a0ed2c 100644 --- a/src/main/github/gh-utils.ts +++ b/src/main/github/gh-utils.ts @@ -1,393 +1,56 @@ import { execFile } from 'child_process' import { promisify } from 'util' import { gitExecFileAsync, ghExecFileAsync, extractExecError } from '../git/runner' -import type { ClassifiedError, GitHubOwnerRepo, IssueSourcePreference } from '../../shared/types' -import { getSshGitProvider } from '../providers/ssh-git-dispatch' -// Why: legacy generic execFile wrapper — only used by callers that don't need -// WSL-aware routing (e.g. non-repo-scoped gh commands). Repo-scoped callers -// should use ghExecFileAsync or gitExecFileAsync from the runner instead. +// Why: legacy generic execFile wrapper - only used by callers that don't need +// WSL-aware routing. Repo-scoped callers should use the runner exports below. export const execFileAsync = promisify(execFile) export { ghExecFileAsync, gitExecFileAsync, extractExecError } +export { classifyGhError, classifyListIssuesError } from './gh-error-classification' +export { + _getOwnerRepoCacheSize, + _resetOwnerRepoCache, + getIssueOwnerRepo, + getOwnerRepo, + getOwnerRepoForRemote, + getRemoteUrlForRepo, + ghRepoExecOptions, + githubRepoContext, + parseGitHubOwnerRepo, + parseGitHubRemoteIdentity, + resolveIssueSource, + resolvePRRepositoryCandidates +} from './github-repository-identity' +export type { + GitHubRemoteIdentity, + GitHubRepoContext, + LocalGitExecOptions, + OwnerRepo, + PRRepositoryCandidates, + ResolvedIssueSource +} from './github-repository-identity' -// Concurrency limiter - max 4 parallel gh processes const MAX_CONCURRENT = 4 let running = 0 const queue: (() => void)[] = [] export function acquire(): Promise { if (running < MAX_CONCURRENT) { - running++ + running += 1 return Promise.resolve() } return new Promise((resolve) => queue.push(() => { - running++ + running += 1 resolve() }) ) } export function release(): void { - running-- + running -= 1 const next = queue.shift() if (next) { next() } } - -// ── Error classification ───────────────────────────────────────────── -// Why: gh CLI surfaces API errors as unstructured stderr. This helper maps -// known patterns to typed errors so callers can show user-friendly messages. -export function classifyGhError(stderr: string): ClassifiedError { - const s = stderr.toLowerCase() - if (s.includes('http 403') || s.includes('resource not accessible')) { - return { - type: 'permission_denied', - message: "You don't have permission to edit this issue. Check your GitHub token scopes." - } - } - // Why: the full gh message is "Could not resolve to a Repository with the - // name ...". Matching the substring 'could not resolve' alone would also - // capture DNS failures like "could not resolve host: api.github.com" and - // misclassify them as not_found. Anchor on the 'repository' qualifier so - // DNS errors fall through to the network_error branch below. - if (s.includes('http 404') || s.includes('could not resolve to a repository')) { - return { type: 'not_found', message: 'Issue not found — it may have been deleted.' } - } - // Why: `gh issue list` prints "the '/' repository has disabled - // issues" when Issues are turned off in repo settings (common on forks). This - // hits during feature-2 when a user flips the selector to an origin fork — - // without a dedicated branch the raw "Command failed: gh issue list …" line - // leaks verbatim into the banner via the `unknown` fallback. - if (s.includes('has disabled issues')) { - return { type: 'issues_disabled', message: 'Issues are disabled on this repository.' } - } - if (s.includes('http 422') || s.includes('validation failed')) { - return { type: 'validation_error', message: `Invalid update — ${stderr.trim()}` } - } - if (s.includes('rate limit')) { - return { - type: 'rate_limited', - message: 'GitHub rate limit hit. Try again in a few minutes.' - } - } - if ( - s.includes('timeout') || - s.includes('no such host') || - s.includes('network') || - s.includes('could not resolve host') - ) { - return { type: 'network_error', message: 'Network error — check your connection.' } - } - return { type: 'unknown', message: `Failed to update issue: ${stderr.trim()}` } -} - -// Why: classifyGhError's copy is phrased for edit/update operations, but -// `listIssues` is a read op and the renderer interpolates err.message verbatim -// into a read-context banner. Rewrite the message for read contexts while -// keeping the typed classification so callers/telemetry are unaffected. -export function classifyListIssuesError(stderr: string): ClassifiedError { - const c = classifyGhError(stderr) - const trimmed = stderr.trim() - // Why: provide an explicit entry for every `ClassifiedError['type']` value - // (even when the copy matches the generic fallback) so the read-context - // rewrite is complete and any newly added error type surfaces as a - // TypeScript error rather than silently falling through to edit-phrased copy. - const readMessages: Record = { - permission_denied: - "You don't have permission to read issues for this repository. Check your GitHub token scopes.", - not_found: 'Repository not found.', - issues_disabled: 'Issues are disabled on this repository.', - validation_error: `Invalid request — ${trimmed}`, - rate_limited: 'GitHub rate limit hit. Try again in a few minutes.', - network_error: 'Network error — check your connection.', - unknown: `Failed to load issues: ${trimmed}` - } - return { type: c.type, message: readMessages[c.type] } -} - -// ── Owner/repo resolution for gh api --cache ────────────────────────── -// Why: alias the shared shape so `src/shared/types.ts#GitHubOwnerRepo` remains -// the single source of truth while main-side call sites can keep using the -// short local name `OwnerRepo`. -export type OwnerRepo = GitHubOwnerRepo - -export type GitHubRemoteIdentity = GitHubOwnerRepo & { host: string } - -export type GitHubRepoContext = { - repoPath: string - connectionId?: string | null -} - -export function githubRepoContext( - repoPath: string, - connectionId?: string | null -): GitHubRepoContext { - return { repoPath, connectionId: connectionId ?? null } -} - -export function ghRepoExecOptions(context: GitHubRepoContext): { - cwd?: string - encoding?: BufferEncoding -} { - // Why: SSH repo paths are meaningful only on the remote host. All GitHub - // calls in this layer pass explicit --repo/API targets, so local gh should - // not receive a remote-only cwd. - return context.connectionId ? {} : { cwd: context.repoPath } -} - -const OWNER_REPO_CACHE_TTL_MS = 30_000 -const OWNER_REPO_CACHE_MAX_ENTRIES = 512 - -type OwnerRepoCacheEntry = { - value: OwnerRepo | null - expiresAt: number -} - -const ownerRepoCache = new Map() -const ownerRepoInFlight = new Map>() - -/** @internal — exposed for tests only */ -export function _resetOwnerRepoCache(): void { - ownerRepoCache.clear() - ownerRepoInFlight.clear() -} - -/** @internal — exposed for tests only */ -export function _getOwnerRepoCacheSize(): number { - return ownerRepoCache.size -} - -function pruneOwnerRepoCache(now: number): void { - for (const [key, entry] of ownerRepoCache) { - if (entry.expiresAt <= now) { - ownerRepoCache.delete(key) - } - } - while (ownerRepoCache.size > OWNER_REPO_CACHE_MAX_ENTRIES) { - const oldestKey = ownerRepoCache.keys().next().value - if (oldestKey === undefined) { - return - } - ownerRepoCache.delete(oldestKey) - } -} - -export function parseGitHubOwnerRepo(remoteUrl: string): OwnerRepo | null { - const identity = parseGitHubRemoteIdentity(remoteUrl) - if (!identity || identity.host.toLowerCase() !== 'github.com') { - return null - } - return { owner: identity.owner, repo: identity.repo } -} - -function normalizeGitHubRemoteHost(host: string): string { - const normalizedHost = host.toLowerCase() - // Why: GitHub documents ssh.github.com:443 as SSH-over-HTTPS for github.com repos. - return normalizedHost === 'ssh.github.com' ? 'github.com' : normalizedHost -} - -function parseGitHubRemotePath(path: string): Pick | null { - const parts = path.replace(/^\/+/, '').replace(/\/+$/, '').split('/') - if (parts.length !== 2) { - return null - } - const [owner, repoWithSuffix] = parts - const repo = repoWithSuffix.replace(/\.git$/i, '') - if (!owner || !repo) { - return null - } - return { owner, repo } -} - -export function parseGitHubRemoteIdentity(remoteUrl: string): GitHubRemoteIdentity | null { - const trimmed = remoteUrl.trim() - const sshMatch = trimmed.match(/^git@([^:]+):([^/]+)\/([^/]+?)(?:\.git)?$/i) - if (sshMatch) { - return { host: normalizeGitHubRemoteHost(sshMatch[1]), owner: sshMatch[2], repo: sshMatch[3] } - } - - try { - const url = new URL(trimmed) - if (!['git:', 'git+ssh:', 'http:', 'https:', 'ssh:'].includes(url.protocol.toLowerCase())) { - return null - } - const path = parseGitHubRemotePath(url.pathname) - return path ? { host: normalizeGitHubRemoteHost(url.hostname), ...path } : null - } catch { - return null - } -} - -export async function getRemoteUrlForRepo( - context: GitHubRepoContext, - remoteName: string -): Promise { - if (context.connectionId) { - const provider = getSshGitProvider(context.connectionId) - if (!provider) { - return null - } - const { stdout } = await provider.exec(['remote', 'get-url', remoteName], context.repoPath) - return stdout - } - const { stdout } = await gitExecFileAsync(['remote', 'get-url', remoteName], { - cwd: context.repoPath - }) - return stdout -} - -export async function getOwnerRepoForRemote( - repoPath: string, - remoteName: string, - connectionId?: string | null -): Promise { - const context = githubRepoContext(repoPath, connectionId) - const cacheKey = `${context.connectionId ?? 'local'}\0${context.repoPath}\0${remoteName}` - const now = Date.now() - pruneOwnerRepoCache(now) - const cached = ownerRepoCache.get(cacheKey) - if (cached && cached.expiresAt > now) { - return cached.value - } - - const inFlight = ownerRepoInFlight.get(cacheKey) - if (inFlight) { - return inFlight - } - - // Why: startup can resolve issue sources, PR candidates, and repo metadata - // for the same repo concurrently. Coalesce missing-remote probes so a stable - // absent upstream does not spawn identical `git remote get-url` processes. - const probe = resolveOwnerRepoForRemote(context, remoteName, cacheKey) - ownerRepoInFlight.set(cacheKey, probe) - try { - return await probe - } finally { - if (ownerRepoInFlight.get(cacheKey) === probe) { - ownerRepoInFlight.delete(cacheKey) - } - } -} - -async function resolveOwnerRepoForRemote( - context: GitHubRepoContext, - remoteName: string, - cacheKey: string -): Promise { - const now = Date.now() - try { - const remoteUrl = await getRemoteUrlForRepo(context, remoteName) - const result = remoteUrl ? parseGitHubOwnerRepo(remoteUrl) : null - if (result) { - ownerRepoCache.set(cacheKey, { - value: result, - expiresAt: now + OWNER_REPO_CACHE_TTL_MS - }) - pruneOwnerRepoCache(now) - return result - } - } catch { - // ignore — non-GitHub remote or no remote - } - ownerRepoCache.set(cacheKey, { value: null, expiresAt: now + OWNER_REPO_CACHE_TTL_MS }) - pruneOwnerRepoCache(now) - return null -} - -export async function getOwnerRepo( - repoPath: string, - connectionId?: string | null -): Promise { - return getOwnerRepoForRemote(repoPath, 'origin', connectionId) -} - -export async function getIssueOwnerRepo( - repoPath: string, - connectionId?: string | null -): Promise { - const upstream = await getOwnerRepoForRemote(repoPath, 'upstream', connectionId) - if (upstream) { - return upstream - } - return getOwnerRepoForRemote(repoPath, 'origin', connectionId) -} - -export type PRRepositoryCandidates = { - candidates: OwnerRepo[] - headRepo: OwnerRepo | null -} - -function ownerRepoKey(ownerRepo: OwnerRepo): string { - return `${ownerRepo.owner.toLowerCase()}/${ownerRepo.repo.toLowerCase()}` -} - -export async function resolvePRRepositoryCandidates( - repoPath: string, - connectionId?: string | null -): Promise { - const upstream = await getOwnerRepoForRemote(repoPath, 'upstream', connectionId) - const origin = await getOwnerRepoForRemote(repoPath, 'origin', connectionId) - const seen = new Set() - const candidates: OwnerRepo[] = [] - - for (const candidate of [upstream, origin]) { - if (!candidate) { - continue - } - const key = ownerRepoKey(candidate) - if (seen.has(key)) { - continue - } - seen.add(key) - candidates.push(candidate) - } - - return { candidates, headRepo: origin } -} - -export type ResolvedIssueSource = { - source: OwnerRepo | null - /** True when the user preferred `upstream` but the upstream remote is no - * longer configured and the resolver fell back to origin. Consumers - * surface this as a one-time toast per session/repo. */ - fellBack: boolean -} - -/** - * Resolve the issue source for a repo honoring the user's per-repo preference. - * - * Do not delete `getIssueOwnerRepo`: it remains the right primitive for - * `'auto'` mode and for preference-agnostic callers like typed work-item - * detail lookups (where the issue-vs-PR disambiguation is orthogonal to - * user choice). - */ -export async function resolveIssueSource( - repoPath: string, - preference: IssueSourcePreference | undefined, - connectionId?: string | null -): Promise { - if (preference === 'upstream') { - const upstream = await getOwnerRepoForRemote(repoPath, 'upstream', connectionId) - if (upstream) { - return { source: upstream, fellBack: false } - } - // Why: explicit upstream is gone — fall back to origin but only flag the - // fallback when it actually produced an origin source. If origin is also - // missing (or non-GitHub), there's nothing to "fall back to" and the - // UI toast "using origin" would be misleading. Do NOT auto-reset the - // preference: the user may be mid-way through a workflow and expect - // their choice to re-engage if `upstream` is re-added. - const origin = await getOwnerRepoForRemote(repoPath, 'origin', connectionId) - return { source: origin, fellBack: origin !== null } - } - if (preference === 'origin') { - return { - source: await getOwnerRepoForRemote(repoPath, 'origin', connectionId), - fellBack: false - } - } - // 'auto' or undefined - return { source: await getIssueOwnerRepo(repoPath, connectionId), fellBack: false } -} diff --git a/src/main/github/github-repository-identity.ts b/src/main/github/github-repository-identity.ts new file mode 100644 index 00000000000..b94a756a8b9 --- /dev/null +++ b/src/main/github/github-repository-identity.ts @@ -0,0 +1,294 @@ +import { gitExecFileAsync } from '../git/runner' +import type { GitHubOwnerRepo, IssueSourcePreference } from '../../shared/types' +import { getSshGitProvider } from '../providers/ssh-git-dispatch' + +export type OwnerRepo = GitHubOwnerRepo + +export type GitHubRemoteIdentity = GitHubOwnerRepo & { host: string } + +export type GitHubRepoContext = { + repoPath: string + connectionId?: string | null + wslDistro?: string +} + +export type LocalGitExecOptions = { + wslDistro?: string +} + +export function githubRepoContext( + repoPath: string, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): GitHubRepoContext { + return { + repoPath, + connectionId: connectionId ?? null, + ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) + } +} + +export function ghRepoExecOptions(context: GitHubRepoContext): { + cwd?: string + encoding?: BufferEncoding + wslDistro?: string +} { + return context.connectionId + ? {} + : { + cwd: context.repoPath, + ...(context.wslDistro ? { wslDistro: context.wslDistro } : {}) + } +} + +const OWNER_REPO_CACHE_TTL_MS = 30_000 +const OWNER_REPO_CACHE_MAX_ENTRIES = 512 + +type OwnerRepoCacheEntry = { + value: OwnerRepo | null + expiresAt: number +} + +const ownerRepoCache = new Map() +const ownerRepoInFlight = new Map>() + +/** @internal - exposed for tests only */ +export function _resetOwnerRepoCache(): void { + ownerRepoCache.clear() + ownerRepoInFlight.clear() +} + +/** @internal - exposed for tests only */ +export function _getOwnerRepoCacheSize(): number { + return ownerRepoCache.size +} + +function pruneOwnerRepoCache(now: number): void { + for (const [key, entry] of ownerRepoCache) { + if (entry.expiresAt <= now) { + ownerRepoCache.delete(key) + } + } + while (ownerRepoCache.size > OWNER_REPO_CACHE_MAX_ENTRIES) { + const oldestKey = ownerRepoCache.keys().next().value + if (oldestKey === undefined) { + return + } + ownerRepoCache.delete(oldestKey) + } +} + +export function parseGitHubOwnerRepo(remoteUrl: string): OwnerRepo | null { + const identity = parseGitHubRemoteIdentity(remoteUrl) + if (!identity || identity.host.toLowerCase() !== 'github.com') { + return null + } + return { owner: identity.owner, repo: identity.repo } +} + +function normalizeGitHubRemoteHost(host: string): string { + const normalizedHost = host.toLowerCase() + // Why: GitHub documents ssh.github.com:443 as SSH-over-HTTPS for github.com repos. + return normalizedHost === 'ssh.github.com' ? 'github.com' : normalizedHost +} + +function parseGitHubRemotePath(path: string): Pick | null { + const parts = path.replace(/^\/+/, '').replace(/\/+$/, '').split('/') + if (parts.length !== 2) { + return null + } + const [owner, repoWithSuffix] = parts + const repo = repoWithSuffix.replace(/\.git$/i, '') + if (!owner || !repo) { + return null + } + return { owner, repo } +} + +export function parseGitHubRemoteIdentity(remoteUrl: string): GitHubRemoteIdentity | null { + const trimmed = remoteUrl.trim() + const sshMatch = trimmed.match(/^git@([^:]+):([^/]+)\/([^/]+?)(?:\.git)?$/i) + if (sshMatch) { + return { host: normalizeGitHubRemoteHost(sshMatch[1]), owner: sshMatch[2], repo: sshMatch[3] } + } + + try { + const url = new URL(trimmed) + if (!['git:', 'git+ssh:', 'http:', 'https:', 'ssh:'].includes(url.protocol.toLowerCase())) { + return null + } + const path = parseGitHubRemotePath(url.pathname) + return path ? { host: normalizeGitHubRemoteHost(url.hostname), ...path } : null + } catch { + return null + } +} + +export async function getRemoteUrlForRepo( + context: GitHubRepoContext, + remoteName: string +): Promise { + if (context.connectionId) { + const provider = getSshGitProvider(context.connectionId) + if (!provider) { + return null + } + const { stdout } = await provider.exec(['remote', 'get-url', remoteName], context.repoPath) + return stdout + } + const { stdout } = await gitExecFileAsync(['remote', 'get-url', remoteName], { + cwd: context.repoPath, + ...(context.wslDistro ? { wslDistro: context.wslDistro } : {}) + }) + return stdout +} + +export async function getOwnerRepoForRemote( + repoPath: string, + remoteName: string, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const context = githubRepoContext(repoPath, connectionId, localGitOptions) + const runtimeKey = context.connectionId ?? `local:${context.wslDistro ?? 'host'}` + const cacheKey = `${runtimeKey}\0${context.repoPath}\0${remoteName}` + const now = Date.now() + pruneOwnerRepoCache(now) + const cached = ownerRepoCache.get(cacheKey) + if (cached && cached.expiresAt > now) { + return cached.value + } + + const inFlight = ownerRepoInFlight.get(cacheKey) + if (inFlight) { + return inFlight + } + + // Why: startup can resolve issue sources, PR candidates, and repo metadata + // for the same repo concurrently. Coalesce missing-remote probes. + const probe = resolveOwnerRepoForRemote(context, remoteName, cacheKey) + ownerRepoInFlight.set(cacheKey, probe) + try { + return await probe + } finally { + if (ownerRepoInFlight.get(cacheKey) === probe) { + ownerRepoInFlight.delete(cacheKey) + } + } +} + +async function resolveOwnerRepoForRemote( + context: GitHubRepoContext, + remoteName: string, + cacheKey: string +): Promise { + const now = Date.now() + try { + const remoteUrl = await getRemoteUrlForRepo(context, remoteName) + const result = remoteUrl ? parseGitHubOwnerRepo(remoteUrl) : null + if (result) { + ownerRepoCache.set(cacheKey, { + value: result, + expiresAt: now + OWNER_REPO_CACHE_TTL_MS + }) + pruneOwnerRepoCache(now) + return result + } + } catch { + // ignore - non-GitHub remote or no remote + } + ownerRepoCache.set(cacheKey, { value: null, expiresAt: now + OWNER_REPO_CACHE_TTL_MS }) + pruneOwnerRepoCache(now) + return null +} + +export async function getOwnerRepo( + repoPath: string, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + return getOwnerRepoForRemote(repoPath, 'origin', connectionId, localGitOptions) +} + +export async function getIssueOwnerRepo( + repoPath: string, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const upstream = await getOwnerRepoForRemote(repoPath, 'upstream', connectionId, localGitOptions) + if (upstream) { + return upstream + } + return getOwnerRepoForRemote(repoPath, 'origin', connectionId, localGitOptions) +} + +export type PRRepositoryCandidates = { + candidates: OwnerRepo[] + headRepo: OwnerRepo | null +} + +function ownerRepoKey(ownerRepo: OwnerRepo): string { + return `${ownerRepo.owner.toLowerCase()}/${ownerRepo.repo.toLowerCase()}` +} + +export async function resolvePRRepositoryCandidates( + repoPath: string, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const upstream = await getOwnerRepoForRemote(repoPath, 'upstream', connectionId, localGitOptions) + const origin = await getOwnerRepoForRemote(repoPath, 'origin', connectionId, localGitOptions) + const seen = new Set() + const candidates: OwnerRepo[] = [] + + for (const candidate of [upstream, origin]) { + if (!candidate) { + continue + } + const key = ownerRepoKey(candidate) + if (seen.has(key)) { + continue + } + seen.add(key) + candidates.push(candidate) + } + + return { candidates, headRepo: origin } +} + +export type ResolvedIssueSource = { + source: OwnerRepo | null + /** True when explicit upstream is gone and resolver fell back to origin. */ + fellBack: boolean +} + +export async function resolveIssueSource( + repoPath: string, + preference: IssueSourcePreference | undefined, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + if (preference === 'upstream') { + const upstream = await getOwnerRepoForRemote( + repoPath, + 'upstream', + connectionId, + localGitOptions + ) + if (upstream) { + return { source: upstream, fellBack: false } + } + const origin = await getOwnerRepoForRemote(repoPath, 'origin', connectionId, localGitOptions) + return { source: origin, fellBack: origin !== null } + } + if (preference === 'origin') { + return { + source: await getOwnerRepoForRemote(repoPath, 'origin', connectionId, localGitOptions), + fellBack: false + } + } + return { + source: await getIssueOwnerRepo(repoPath, connectionId, localGitOptions), + fellBack: false + } +} diff --git a/src/main/github/issues.test.ts b/src/main/github/issues.test.ts index 1f857ca02fc..1c8e5c5596f 100644 --- a/src/main/github/issues.test.ts +++ b/src/main/github/issues.test.ts @@ -27,7 +27,15 @@ vi.mock('./gh-utils', async () => { } }) -import { createIssue, getIssue, listIssues, updateIssue } from './issues' +import { + addIssueComment, + createIssue, + getIssue, + listAssignableUsers, + listIssues, + listLabels, + updateIssue +} from './issues' describe('issue source operations', () => { beforeEach(() => { @@ -65,6 +73,71 @@ describe('issue source operations', () => { ) }) + it('routes local WSL issue operations through repo resolution and gh execution options', async () => { + const localGitOptions = { wslDistro: 'Ubuntu' } + getIssueOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' }) + resolveIssueSourceMock.mockResolvedValue({ + source: { owner: 'stablyai', repo: 'orca' }, + fellBack: false + }) + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 923, + title: 'Use upstream issues', + state: 'open', + html_url: 'https://github.com/stablyai/orca/issues/923', + labels: [] + }) + }) + .mockResolvedValueOnce({ stdout: '[]' }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 924, + html_url: 'https://github.com/stablyai/orca/issues/924' + }) + }) + .mockResolvedValueOnce({ stdout: '' }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + id: 1, + user: { login: 'octo', avatar_url: '', type: 'User' }, + body: 'Comment', + created_at: '2026-06-16T00:00:00.000Z', + html_url: 'https://github.com/stablyai/orca/issues/923#issuecomment-1' + }) + }) + .mockResolvedValueOnce({ stdout: 'bug\nfrontend\n' }) + .mockResolvedValueOnce({ stdout: '{"login":"octo","avatar_url":""}\n' }) + + await getIssue('/repo-root', 923, null, localGitOptions) + await listIssues('/repo-root', 5, undefined, null, localGitOptions) + await createIssue( + '/repo-root', + 'New issue', + 'Body', + undefined, + null, + undefined, + localGitOptions + ) + await updateIssue('/repo-root', 923, { body: 'Updated' }, null, localGitOptions) + await addIssueComment('/repo-root', 923, 'Comment', null, null, localGitOptions) + await listLabels('/repo-root', undefined, null, localGitOptions) + await listAssignableUsers('/repo-root', undefined, null, localGitOptions) + + expect(getIssueOwnerRepoMock).toHaveBeenCalledWith('/repo-root', null, localGitOptions) + expect(resolveIssueSourceMock).toHaveBeenCalledWith( + '/repo-root', + undefined, + null, + localGitOptions + ) + expect(ghExecFileAsyncMock.mock.calls.every((call) => call[1]?.wslDistro === 'Ubuntu')).toBe( + true + ) + }) + it('lists issues from the issue owner/repo', async () => { getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) diff --git a/src/main/github/issues.ts b/src/main/github/issues.ts index 4f8c5ad9887..8f80fc6451f 100644 --- a/src/main/github/issues.ts +++ b/src/main/github/issues.ts @@ -13,7 +13,7 @@ import type { PRComment } from '../../shared/types' import { mapIssueInfo } from './mappers' -import type { OwnerRepo } from './gh-utils' +import type { LocalGitExecOptions, OwnerRepo } from './gh-utils' // prettier-ignore import { ghExecFileAsync, acquire, release, getIssueOwnerRepo, resolveIssueSource, classifyGhError, classifyListIssuesError, ghRepoExecOptions, githubRepoContext } from './gh-utils' @@ -45,11 +45,12 @@ export type IssueListResult = { export async function getIssue( repoPath: string, issueNumber: number, - connectionId?: string | null + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} ): Promise { - const context = githubRepoContext(repoPath, connectionId) + const context = githubRepoContext(repoPath, connectionId, localGitOptions) const ghOptions = ghRepoExecOptions(context) - const ownerRepo = await getIssueOwnerRepo(repoPath, connectionId) + const ownerRepo = await getIssueOwnerRepo(repoPath, connectionId, localGitOptions) await acquire() try { if (ownerRepo) { @@ -94,11 +95,17 @@ export async function listIssues( repoPath: string, limit = 20, preference?: IssueSourcePreference, - connectionId?: string | null + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} ): Promise { - const context = githubRepoContext(repoPath, connectionId) + const context = githubRepoContext(repoPath, connectionId, localGitOptions) const ghOptions = ghRepoExecOptions(context) - const { source: ownerRepo } = await resolveIssueSource(repoPath, preference, connectionId) + const { source: ownerRepo } = await resolveIssueSource( + repoPath, + preference, + connectionId, + localGitOptions + ) await acquire() try { if (ownerRepo) { @@ -153,15 +160,21 @@ export async function createIssue( body: string, preference?: IssueSourcePreference, connectionId?: string | null, - fields?: GitHubCreateIssueFields + fields?: GitHubCreateIssueFields, + localGitOptions: LocalGitExecOptions = {} ): Promise<{ ok: true; number: number; url: string } | { ok: false; error: string }> { const trimmedTitle = title.trim() if (!trimmedTitle) { return { ok: false, error: 'Title is required' } } - const context = githubRepoContext(repoPath, connectionId) + const context = githubRepoContext(repoPath, connectionId, localGitOptions) const ghOptions = ghRepoExecOptions(context) - const { source: ownerRepo } = await resolveIssueSource(repoPath, preference, connectionId) + const { source: ownerRepo } = await resolveIssueSource( + repoPath, + preference, + connectionId, + localGitOptions + ) if (!ownerRepo) { return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } } @@ -219,11 +232,12 @@ export async function updateIssue( repoPath: string, issueNumber: number, updates: GitHubIssueUpdate, - connectionId?: string | null + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} ): Promise<{ ok: true } | { ok: false; error: string }> { - const context = githubRepoContext(repoPath, connectionId) + const context = githubRepoContext(repoPath, connectionId, localGitOptions) const ghOptions = ghRepoExecOptions(context) - const ownerRepo = await getIssueOwnerRepo(repoPath, connectionId) + const ownerRepo = await getIssueOwnerRepo(repoPath, connectionId, localGitOptions) if (!ownerRepo) { return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } } @@ -341,11 +355,13 @@ export async function addIssueComment( issueNumber: number, body: string, connectionId?: string | null, - ownerRepoOverride?: OwnerRepo | null + ownerRepoOverride?: OwnerRepo | null, + localGitOptions: LocalGitExecOptions = {} ): Promise { - const context = githubRepoContext(repoPath, connectionId) + const context = githubRepoContext(repoPath, connectionId, localGitOptions) const ghOptions = ghRepoExecOptions(context) - const ownerRepo = ownerRepoOverride ?? (await getIssueOwnerRepo(repoPath, connectionId)) + const ownerRepo = + ownerRepoOverride ?? (await getIssueOwnerRepo(repoPath, connectionId, localGitOptions)) if (!ownerRepo) { return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } } @@ -393,11 +409,17 @@ export async function addIssueComment( export async function listLabels( repoPath: string, preference?: IssueSourcePreference, - connectionId?: string | null + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} ): Promise { - const context = githubRepoContext(repoPath, connectionId) + const context = githubRepoContext(repoPath, connectionId, localGitOptions) const ghOptions = ghRepoExecOptions(context) - const { source: ownerRepo } = await resolveIssueSource(repoPath, preference, connectionId) + const { source: ownerRepo } = await resolveIssueSource( + repoPath, + preference, + connectionId, + localGitOptions + ) if (!ownerRepo) { return [] } @@ -427,11 +449,17 @@ export async function listLabels( export async function listAssignableUsers( repoPath: string, preference?: IssueSourcePreference, - connectionId?: string | null + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} ): Promise { - const context = githubRepoContext(repoPath, connectionId) + const context = githubRepoContext(repoPath, connectionId, localGitOptions) const ghOptions = ghRepoExecOptions(context) - const { source: ownerRepo } = await resolveIssueSource(repoPath, preference, connectionId) + const { source: ownerRepo } = await resolveIssueSource( + repoPath, + preference, + connectionId, + localGitOptions + ) if (!ownerRepo) { return [] } diff --git a/src/main/github/pr-head-tracking-ref.ts b/src/main/github/pr-head-tracking-ref.ts index 65f585d3784..3e3fd82e3d3 100644 --- a/src/main/github/pr-head-tracking-ref.ts +++ b/src/main/github/pr-head-tracking-ref.ts @@ -1,17 +1,26 @@ import { gitExecFileAsync } from '../git/runner' import type { SshGitProvider } from '../providers/ssh-git-provider' +type LocalGitExecOptions = { + cwd: string + wslDistro?: string +} + // Why: the relay's read-only git.exec channel rejects `fetch`, so SSH repos // must use the dedicated git.fetchRemoteTrackingRef RPC. export async function fetchPrHeadTrackingRef( repo: { path: string; connectionId?: string | null }, sshGitProvider: SshGitProvider | null | undefined, remote: string, - branch: string + branch: string, + options: { localGitExecOptions?: LocalGitExecOptions } = {} ): Promise { const ref = `refs/remotes/${remote}/${branch}` if (!repo.connectionId) { - await gitExecFileAsync(['fetch', remote, `+refs/heads/${branch}:${ref}`], { cwd: repo.path }) + await gitExecFileAsync( + ['fetch', remote, `+refs/heads/${branch}:${ref}`], + options.localGitExecOptions ?? { cwd: repo.path } + ) return } if (!sshGitProvider) { diff --git a/src/main/github/pr-refresh-coordinator.test.ts b/src/main/github/pr-refresh-coordinator.test.ts index 57e20095141..3200587fc7a 100644 --- a/src/main/github/pr-refresh-coordinator.test.ts +++ b/src/main/github/pr-refresh-coordinator.test.ts @@ -355,6 +355,53 @@ describe('pr-refresh-coordinator', () => { ) }) + it('does not coalesce host and WSL refreshes for the same local branch', async () => { + const { enqueuePRRefresh } = await import('./pr-refresh-coordinator') + getPRForBranchOutcomeMock + .mockResolvedValueOnce({ + kind: 'found', + pr: makePR({ number: 12 }), + fetchedAt: Date.now() + }) + .mockResolvedValueOnce({ + kind: 'found', + pr: makePR({ number: 44 }), + fetchedAt: Date.now() + }) + + enqueuePRRefresh(makeCandidate({ cacheKey: 'host::repo-1::feature/test' }), 'active', 80, 1) + enqueuePRRefresh( + makeCandidate({ + cacheKey: 'wsl::repo-1::feature/test', + localGitOptions: { wslDistro: 'Ubuntu' } + }), + 'active', + 80, + 1 + ) + await vi.runOnlyPendingTimersAsync() + await vi.runOnlyPendingTimersAsync() + + expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2) + expect(getPRForBranchOutcomeMock).toHaveBeenNthCalledWith( + 1, + '/repo', + 'feature/test', + null, + null, + null + ) + expect(getPRForBranchOutcomeMock).toHaveBeenNthCalledWith( + 2, + '/repo', + 'feature/test', + null, + null, + null, + { localGitExecOptions: { wslDistro: 'Ubuntu' } } + ) + }) + it('preserves coalesced aliases across visible follow-up refreshes', async () => { const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator') getPRForBranchOutcomeMock diff --git a/src/main/github/pr-refresh-coordinator.ts b/src/main/github/pr-refresh-coordinator.ts index 58965406cbf..b8396fc1068 100644 --- a/src/main/github/pr-refresh-coordinator.ts +++ b/src/main/github/pr-refresh-coordinator.ts @@ -10,6 +10,7 @@ import type { GitHubPRRefreshSkippedReason, PRRefreshOutcome } from '../../shared/types' +import type { HostedReviewExecutionOptions } from '../source-control/hosted-review-git-options' import { getPRForBranchOutcome } from './client' import { getRateLimit, noteRateLimitSpend, rateLimitGuard } from './rate-limit' @@ -29,6 +30,14 @@ type PRRefreshOutcomeObserver = ( outcome: PRRefreshOutcome ) => void +function hostedReviewOptionArgs( + localGitOptions?: GitHubPRRefreshCandidate['localGitOptions'] +): [] | [HostedReviewExecutionOptions] { + return localGitOptions?.wslDistro + ? [{ localGitExecOptions: { wslDistro: localGitOptions.wslDistro } }] + : [] +} + const MIN_BACKGROUND_REFRESH_AGE_MS = 60_000 const MERGEABILITY_PENDING_REFRESH_MS = 10_000 const MANUAL_MERGEABILITY_PENDING_REFRESH_MS = 2_500 @@ -93,10 +102,13 @@ function broadcast(event: Omit, sequenceOverri function refreshKey(candidate: GitHubPRRefreshCandidate): string { const connectionScope = candidate.connectionId ?? 'local' + const runtimeScope = candidate.connectionId + ? 'remote' + : `runtime:${candidate.localGitOptions?.wslDistro ? `wsl:${candidate.localGitOptions.wslDistro}` : 'host'}` if (typeof candidate.linkedPRNumber === 'number') { - return `${connectionScope}::${candidate.repoPath}::pr::${candidate.linkedPRNumber}` + return `${connectionScope}::${runtimeScope}::${candidate.repoPath}::pr::${candidate.linkedPRNumber}` } - return `${connectionScope}::${candidate.repoPath}::branch::${candidate.branch}` + return `${connectionScope}::${runtimeScope}::${candidate.repoPath}::branch::${candidate.branch}` } function isVisibleKey(key: string): boolean { @@ -519,7 +531,8 @@ async function drainQueue(): Promise { next.candidate.branch, next.candidate.linkedPRNumber ?? null, next.candidate.connectionId ?? null, - next.candidate.linkedPRNumber == null ? (next.candidate.fallbackPRNumber ?? null) : null + next.candidate.linkedPRNumber == null ? (next.candidate.fallbackPRNumber ?? null) : null, + ...hostedReviewOptionArgs(next.candidate.localGitOptions) ) outcomeObserver?.(next.candidate, outcome) broadcast({ aliases, reason: next.reason, outcome, requestStartedAt }, requestSequence) @@ -645,7 +658,8 @@ export async function refreshPRNow(candidate: GitHubPRRefreshCandidate): Promise candidate.branch, candidate.linkedPRNumber ?? null, candidate.connectionId ?? null, - candidate.linkedPRNumber == null ? (candidate.fallbackPRNumber ?? null) : null + candidate.linkedPRNumber == null ? (candidate.fallbackPRNumber ?? null) : null, + ...hostedReviewOptionArgs(candidate.localGitOptions) ) outcomeObserver?.(candidate, outcome) broadcast({ aliases, reason: 'manual', outcome, requestStartedAt }, requestSequence) diff --git a/src/main/github/pr-start-point.ts b/src/main/github/pr-start-point.ts index 2a02c4f53eb..f5511981d4a 100644 --- a/src/main/github/pr-start-point.ts +++ b/src/main/github/pr-start-point.ts @@ -11,6 +11,7 @@ type ResolveGitHubPrStartPointArgs = { baseRefName?: string isCrossRepository?: boolean connectionId?: string | null + localGitOptions?: { wslDistro?: string } gitExec: GitExec fetchRemoteTrackingRef: (remote: string, branch: string) => Promise resolveRemote: () => Promise @@ -18,6 +19,12 @@ type ResolveGitHubPrStartPointArgs = { type ResolveGitHubPrStartPointResult = GitHubPrStartPoint | { error: string } +function localGitOptionArgs( + options: { wslDistro?: string } | undefined +): [] | [{ wslDistro?: string }] { + return options && Object.keys(options).length > 0 ? [options] : [] +} + export async function resolveGitHubPrStartPoint( args: ResolveGitHubPrStartPointArgs ): Promise { @@ -35,7 +42,8 @@ export async function resolveGitHubPrStartPoint( const resolved = await getPullRequestPushTarget( args.repoPath, args.prNumber, - args.connectionId ?? null + args.connectionId ?? null, + ...localGitOptionArgs(args.localGitOptions) ) pushTarget = resolved?.pushTarget maintainerCanModify = resolved?.maintainerCanModify @@ -47,7 +55,13 @@ export async function resolveGitHubPrStartPoint( } if (!headRefName) { - const item = await getWorkItem(args.repoPath, args.prNumber, 'pr', args.connectionId ?? null) + const item = await getWorkItem( + args.repoPath, + args.prNumber, + 'pr', + args.connectionId ?? null, + ...localGitOptionArgs(args.localGitOptions) + ) if (!item || item.type !== 'pr') { return { error: `PR #${args.prNumber} not found.` } } diff --git a/src/main/github/work-item-details.test.ts b/src/main/github/work-item-details.test.ts index 30517f8c4ae..2a7dc2fda85 100644 --- a/src/main/github/work-item-details.test.ts +++ b/src/main/github/work-item-details.test.ts @@ -27,11 +27,14 @@ const { rateLimitGuardMock: vi.fn<() => RateLimitGuardResult>(() => ({ blocked: false })), noteRateLimitSpendMock: vi.fn(), ghRepoExecOptionsMock: vi.fn((context) => - context.connectionId ? {} : { cwd: context.repoPath } + context.connectionId + ? {} + : { cwd: context.repoPath, ...(context.wslDistro ? { wslDistro: context.wslDistro } : {}) } ), - githubRepoContextMock: vi.fn((repoPath, connectionId) => ({ + githubRepoContextMock: vi.fn((repoPath, connectionId, localGitOptions) => ({ repoPath, - connectionId: connectionId ?? null + connectionId: connectionId ?? null, + ...localGitOptions })), acquireMock: vi.fn(), releaseMock: vi.fn() @@ -241,4 +244,85 @@ describe('getWorkItemDetails', () => { expect(ghExecFileAsyncMock.mock.calls[0][1]).toEqual({}) expect(details?.body).toBe('Remote issue body') }) + + it('routes local WSL PR detail fan-out through the selected distro', async () => { + const localGitOptions = { wslDistro: 'Ubuntu' } + getWorkItemMock.mockResolvedValueOnce({ + id: 'pr:42', + type: 'pr', + number: 42, + title: 'Review drawer WSL', + state: 'open', + url: 'https://github.com/stablyai/orca/pull/42', + labels: [], + updatedAt: '2026-04-01T00:00:00Z', + author: 'octocat' + }) + getOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' }) + getPRCommentsMock.mockResolvedValue([]) + getPRChecksMock.mockResolvedValue([]) + ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { + const target = args.at(-1) + if (target === 'repos/stablyai/orca/pulls/42') { + return { + stdout: JSON.stringify({ + body: 'PR body', + head: { sha: 'head-sha' }, + base: { sha: 'base-sha' } + }) + } + } + if (target === 'repos/stablyai/orca/pulls/42/files?per_page=100') { + return { stdout: '[]' } + } + const query = args.find((arg) => arg.startsWith('query=')) ?? '' + if (query.includes('viewerViewedState')) { + return { + stdout: JSON.stringify({ + data: { + repository: { + pullRequest: { + id: 'PR_kwDO123', + files: { pageInfo: { hasNextPage: false }, nodes: [] } + } + } + } + }) + } + } + if (query.includes('participants(first: 100)')) { + return { + stdout: JSON.stringify({ + data: { repository: { pullRequest: { participants: { nodes: [] } } } } + }) + } + } + return { stdout: JSON.stringify({ data: {} }) } + }) + + const details = await getWorkItemDetails('/repo-root', 42, 'pr', null, localGitOptions) + + expect(details?.body).toBe('PR body') + expect(getWorkItemMock).toHaveBeenCalledWith('/repo-root', 42, 'pr', null, localGitOptions) + expect(getOwnerRepoMock).toHaveBeenCalledWith('/repo-root', null, localGitOptions) + expect(getPRCommentsMock).toHaveBeenCalledWith( + '/repo-root', + 42, + undefined, + null, + localGitOptions + ) + expect(getPRChecksMock).toHaveBeenCalledWith( + '/repo-root', + 42, + 'head-sha', + null, + undefined, + null, + localGitOptions + ) + expect(ghExecFileAsyncMock.mock.calls.every((call) => call[1]?.wslDistro === 'Ubuntu')).toBe( + true + ) + }) }) diff --git a/src/main/github/work-item-details.ts b/src/main/github/work-item-details.ts index e507e8a8fa3..e79f13577a3 100644 --- a/src/main/github/work-item-details.ts +++ b/src/main/github/work-item-details.ts @@ -18,7 +18,8 @@ import { getOwnerRepo, getIssueOwnerRepo, ghRepoExecOptions, - githubRepoContext + githubRepoContext, + type LocalGitExecOptions } from './gh-utils' import { getWorkItem, getPRChecks, getPRComments } from './client' import { noteRateLimitSpend, rateLimitGuard } from './rate-limit' @@ -34,6 +35,10 @@ const MAX_PR_FILES = 300 // the safety fallback. const GITHUB_RAW_CONTENT_MAX_BUFFER_BYTES = 8 * 1024 * 1024 +function localGitOptionArgs(options: LocalGitExecOptions = {}): [] | [LocalGitExecOptions] { + return Object.keys(options).length > 0 ? [options] : [] +} + const PR_FILE_VIEWED_STATES_QUERY = `query($owner: String!, $repo: String!, $number: Int!, $after: String) { repository(owner: $owner, name: $repo) { pullRequest(number: $number) { @@ -124,15 +129,20 @@ type GraphQLIssueDetailsResponse = { async function getIssueDetailsViaGraphQL( repoPath: string, issueNumber: number, - connectionId?: string | null + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} ): Promise<{ body: string comments: PRComment[] assignees: string[] participants: GitHubAssignableUser[] } | null> { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId)) - const ownerRepo = await getIssueOwnerRepo(repoPath, connectionId) + const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) + const ownerRepo = await getIssueOwnerRepo( + repoPath, + connectionId, + ...localGitOptionArgs(localGitOptions) + ) if (!ownerRepo) { return null } @@ -274,10 +284,15 @@ function isBinaryHint(file: RESTPRFile): boolean { async function getPRHeadBaseSha( repoPath: string, prNumber: number, - connectionId?: string | null + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} ): Promise<{ headSha: string; baseSha: string } | null> { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId)) - const ownerRepo = await getOwnerRepo(repoPath, connectionId) + const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) + const ownerRepo = await getOwnerRepo( + repoPath, + connectionId, + ...localGitOptionArgs(localGitOptions) + ) try { if (ownerRepo) { const { stdout } = await ghExecFileAsync( @@ -313,10 +328,15 @@ async function getPRHeadBaseSha( async function getPRFiles( repoPath: string, prNumber: number, - connectionId?: string | null + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} ): Promise { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId)) - const ownerRepo = await getOwnerRepo(repoPath, connectionId) + const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) + const ownerRepo = await getOwnerRepo( + repoPath, + connectionId, + ...localGitOptionArgs(localGitOptions) + ) if (!ownerRepo) { return [] } @@ -353,10 +373,15 @@ type PRFileViewedStatesResult = { async function getPRFileViewedStates( repoPath: string, prNumber: number, - connectionId?: string | null + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} ): Promise { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId)) - const ownerRepo = await getOwnerRepo(repoPath, connectionId) + const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) + const ownerRepo = await getOwnerRepo( + repoPath, + connectionId, + ...localGitOptionArgs(localGitOptions) + ) if (!ownerRepo) { return null } @@ -444,10 +469,15 @@ function mergePRFileViewedStates( async function getIssueBodyAndComments( repoPath: string, issueNumber: number, - connectionId?: string | null + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} ): Promise<{ body: string; comments: PRComment[]; assignees: string[] }> { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId)) - const ownerRepo = await getIssueOwnerRepo(repoPath, connectionId) + const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) + const ownerRepo = await getIssueOwnerRepo( + repoPath, + connectionId, + ...localGitOptionArgs(localGitOptions) + ) try { if (ownerRepo) { const [issueResult, commentsResult] = await Promise.all([ @@ -530,10 +560,15 @@ async function getIssueBodyAndComments( async function getPRBody( repoPath: string, prNumber: number, - connectionId?: string | null + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} ): Promise { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId)) - const ownerRepo = await getOwnerRepo(repoPath, connectionId) + const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) + const ownerRepo = await getOwnerRepo( + repoPath, + connectionId, + ...localGitOptionArgs(localGitOptions) + ) try { if (ownerRepo) { const { stdout } = await ghExecFileAsync( @@ -557,15 +592,16 @@ async function getPRBody( async function getWorkItemParticipants( repoPath: string, item: Pick, - connectionId?: string | null + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} ): Promise { // Why: issues in a fork live on the upstream remote, so participants must be // resolved via getIssueOwnerRepo to stay consistent with getIssueBodyAndComments. // PRs remain tied to origin via getOwnerRepo. const ownerRepo = item.type === 'issue' - ? await getIssueOwnerRepo(repoPath, connectionId) - : await getOwnerRepo(repoPath, connectionId) + ? await getIssueOwnerRepo(repoPath, connectionId, ...localGitOptionArgs(localGitOptions)) + : await getOwnerRepo(repoPath, connectionId, ...localGitOptionArgs(localGitOptions)) if (!ownerRepo) { return [] } @@ -589,7 +625,7 @@ async function getWorkItemParticipants( '-F', `isPr=${item.type === 'pr'}` ], - ghRepoExecOptions(githubRepoContext(repoPath, connectionId)) + ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) ) const data = JSON.parse(stdout) as { data?: { @@ -622,7 +658,8 @@ async function getWorkItemParticipants( async function getGitHubUsersByLogin( repoPath: string, logins: string[], - connectionId?: string | null + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} ): Promise { const uniqueLogins = Array.from( new Set(logins.filter((login) => login && login !== 'ghost').map((login) => login.trim())) @@ -643,7 +680,7 @@ async function getGitHubUsersByLogin( noteRateLimitSpend('graphql') const { stdout } = await ghExecFileAsync( ['api', 'graphql', '-f', `query=query { ${fields} }`], - ghRepoExecOptions(githubRepoContext(repoPath, connectionId)) + ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) ) const data = JSON.parse(stdout) as { data?: Record< @@ -680,14 +717,20 @@ async function getMentionParticipants( item: Pick, comments: PRComment[], participants: GitHubAssignableUser[], - connectionId?: string | null + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} ): Promise { const visibleLogins = [item.author ?? '', ...comments.map((comment) => comment.author)] // Why: one aliased GraphQL query returns login/name/avatarUrl for every // mentioned author in a single round-trip. The previous REST fan-out // (/users/) returned the same fields but cost one rate-limit point // per user. - const graphQlUsers = await getGitHubUsersByLogin(repoPath, visibleLogins, connectionId) + const graphQlUsers = await getGitHubUsersByLogin( + repoPath, + visibleLogins, + connectionId, + localGitOptions + ) return mergeGitHubUsers([...participants, ...graphQlUsers]) } @@ -695,10 +738,19 @@ async function getPRChecksForDetails( repoPath: string, prNumber: number, headSha: string | undefined, - connectionId?: string | null + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} ): Promise { try { - return await getPRChecks(repoPath, prNumber, headSha, null, undefined, connectionId) + return await getPRChecks( + repoPath, + prNumber, + headSha, + null, + undefined, + connectionId, + ...localGitOptionArgs(localGitOptions) + ) } catch (err) { // Why: checks are auxiliary PR metadata; a gh CLI edge case must not block // the user from opening the PR review drawer and reading the files/comments. @@ -711,7 +763,8 @@ export async function getWorkItemDetails( repoPath: string, number: number, type?: 'issue' | 'pr', - connectionId?: string | null + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} ): Promise { // Why: getWorkItem already handles acquire/release. We call it first (outside // our semaphore) so the known-cheap lookup doesn't compete with the richer @@ -720,7 +773,8 @@ export async function getWorkItemDetails( repoPath, number, type, - connectionId + connectionId, + ...localGitOptionArgs(localGitOptions) ) if (!item) { return null @@ -736,7 +790,12 @@ export async function getWorkItemDetails( // is preserved. The GraphQL `participants` connection includes every // commenter, so we skip the extra `getMentionParticipants` aliased // user-hydration trip when the collapsed path succeeds. - const collapsed = await getIssueDetailsViaGraphQL(repoPath, item.number, connectionId) + const collapsed = await getIssueDetailsViaGraphQL( + repoPath, + item.number, + connectionId, + localGitOptions + ) if (collapsed) { return { item, @@ -749,15 +808,16 @@ export async function getWorkItemDetails( // Why: fall back to body/comments and GraphQL participants in parallel; // the mention-participant merge is a cheap local operation afterward. const [{ body, comments, assignees }, participants] = await Promise.all([ - getIssueBodyAndComments(repoPath, item.number, connectionId), - getWorkItemParticipants(repoPath, item, connectionId) + getIssueBodyAndComments(repoPath, item.number, connectionId, localGitOptions), + getWorkItemParticipants(repoPath, item, connectionId, localGitOptions) ]) const mentionParticipants = await getMentionParticipants( repoPath, item, comments, participants, - connectionId + connectionId, + localGitOptions ) return { item, @@ -770,20 +830,26 @@ export async function getWorkItemDetails( // PR: fetch body + comments + checks + files + head/base SHAs in parallel. const [body, comments, shas, files, viewedStates, participants] = await Promise.all([ - getPRBody(repoPath, item.number, connectionId), - getPRComments(repoPath, item.number, undefined, connectionId), - getPRHeadBaseSha(repoPath, item.number, connectionId), - getPRFiles(repoPath, item.number, connectionId), - getPRFileViewedStates(repoPath, item.number, connectionId), - getWorkItemParticipants(repoPath, item, connectionId) + getPRBody(repoPath, item.number, connectionId, localGitOptions), + getPRComments( + repoPath, + item.number, + undefined, + connectionId, + ...localGitOptionArgs(localGitOptions) + ), + getPRHeadBaseSha(repoPath, item.number, connectionId, localGitOptions), + getPRFiles(repoPath, item.number, connectionId, localGitOptions), + getPRFileViewedStates(repoPath, item.number, connectionId, localGitOptions), + getWorkItemParticipants(repoPath, item, connectionId, localGitOptions) ]) // Why: run the mention-author GraphQL lookup in parallel with the final // checks fetch instead of serially — both depend only on data from the // Promise.all above, so there's no ordering requirement between them. const [mentionParticipants, checks] = await Promise.all([ - getMentionParticipants(repoPath, item, comments, participants, connectionId), - getPRChecksForDetails(repoPath, item.number, shas?.headSha, connectionId) + getMentionParticipants(repoPath, item, comments, participants, connectionId, localGitOptions), + getPRChecksForDetails(repoPath, item.number, shas?.headSha, connectionId, localGitOptions) ]) return { @@ -809,6 +875,7 @@ export async function getWorkItemDetails( async function fetchContentAtRef(args: { repoPath: string connectionId?: string | null + localGitOptions?: LocalGitExecOptions owner: string repo: string path: string @@ -825,7 +892,9 @@ async function fetchContentAtRef(args: { `repos/${args.owner}/${args.repo}/contents/${encodeURI(args.path)}?ref=${encodeURIComponent(args.ref)}` ], { - ...ghRepoExecOptions(githubRepoContext(args.repoPath, args.connectionId)), + ...ghRepoExecOptions( + githubRepoContext(args.repoPath, args.connectionId, args.localGitOptions) + ), maxBuffer: GITHUB_RAW_CONTENT_MAX_BUFFER_BYTES } ) @@ -848,6 +917,7 @@ async function fetchContentAtRef(args: { export async function getPRFileContents(args: { repoPath: string connectionId?: string | null + localGitOptions?: LocalGitExecOptions prNumber: number path: string oldPath?: string @@ -855,7 +925,11 @@ export async function getPRFileContents(args: { headSha: string baseSha: string }): Promise { - const ownerRepo = await getOwnerRepo(args.repoPath, args.connectionId) + const ownerRepo = await getOwnerRepo( + args.repoPath, + args.connectionId, + ...localGitOptionArgs(args.localGitOptions) + ) if (!ownerRepo) { return { original: '', @@ -880,6 +954,7 @@ export async function getPRFileContents(args: { ? fetchContentAtRef({ repoPath: args.repoPath, connectionId: args.connectionId, + localGitOptions: args.localGitOptions, owner: ownerRepo.owner, repo: ownerRepo.repo, path: originalPath, @@ -893,6 +968,7 @@ export async function getPRFileContents(args: { ? fetchContentAtRef({ repoPath: args.repoPath, connectionId: args.connectionId, + localGitOptions: args.localGitOptions, owner: ownerRepo.owner, repo: ownerRepo.repo, path: args.path, diff --git a/src/main/gitlab/client-mr.test.ts b/src/main/gitlab/client-mr.test.ts index 53621e2a3ea..cc2f3de60f8 100644 --- a/src/main/gitlab/client-mr.test.ts +++ b/src/main/gitlab/client-mr.test.ts @@ -38,13 +38,18 @@ vi.mock('./gl-utils', async () => { import { _getGitLabRateLimitCacheSize, _resetGitLabRateLimitCache, + addMRComment, getMergeRequest, getMergeRequestForBranch, getJobTrace, addMRInlineComment, + closeMR, diagnoseAuth, getRateLimit, listMergeRequests, + listWorkItems, + mergeMR, + reopenMR, resolveMRDiscussion, retryJob, updateMR, @@ -69,6 +74,101 @@ describe('gitlab client — MR operations', () => { }) }) + it('routes local WSL MR review-management and job actions through project resolution and glab options', async () => { + const localGitOptions = { wslDistro: 'Ubuntu' } + glabExecFileAsyncMock + .mockResolvedValueOnce({ stdout: '{}' }) + .mockResolvedValueOnce({ stdout: '{}' }) + .mockResolvedValueOnce({ stdout: '{}' }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + id: 1, + author: { username: 'alice', avatar_url: '', state: 'active' }, + body: 'Comment', + created_at: '2026-06-16T00:00:00.000Z' + }) + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + id: 'discussion-1', + notes: [ + { + id: 2, + author: { username: 'alice', avatar_url: '', state: 'active' }, + body: 'Inline', + created_at: '2026-06-16T00:00:00.000Z', + position: { new_path: 'src/app.ts', new_line: 12 } + } + ] + }) + }) + .mockResolvedValueOnce({ stdout: '{}' }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + reviewers: [{ id: 1, username: 'alice', name: 'Alice', avatar_url: '', state: 'active' }] + }) + }) + .mockResolvedValueOnce({ stdout: 'trace output' }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + id: 100, + pipeline: { id: 50 }, + name: 'test', + stage: 'test', + status: 'pending', + web_url: 'https://gitlab.com/g/p/-/jobs/100', + duration: null + }) + }) + .mockResolvedValueOnce({ stdout: '{}' }) + + await closeMR('/repo', 12, undefined, null, undefined, localGitOptions) + await reopenMR('/repo', 12, undefined, null, undefined, localGitOptions) + await mergeMR('/repo', 12, 'squash', undefined, null, undefined, localGitOptions) + await addMRComment('/repo', 12, 'Comment', undefined, null, undefined, localGitOptions) + await addMRInlineComment( + '/repo', + 12, + { + body: 'Inline', + path: 'src/app.ts', + line: 12, + baseSha: 'base', + startSha: 'start', + headSha: 'head' + }, + undefined, + null, + undefined, + localGitOptions + ) + await resolveMRDiscussion( + '/repo', + 12, + 'discussion-1', + true, + undefined, + null, + undefined, + localGitOptions + ) + await updateMRReviewers('/repo', 12, [1], undefined, null, undefined, localGitOptions) + await getJobTrace('/repo', 99, undefined, null, undefined, localGitOptions) + await retryJob('/repo', 99, undefined, null, undefined, localGitOptions) + await updateMR('/repo', 12, { title: 'Renamed' }, undefined, null, undefined, localGitOptions) + + expect(resolveIssueSourceMock).toHaveBeenCalledWith( + '/repo', + undefined, + ['gitlab.com'], + null, + localGitOptions + ) + expect(glabExecFileAsyncMock.mock.calls.every((call) => call[1]?.wslDistro === 'Ubuntu')).toBe( + true + ) + }) + describe('getMergeRequest', () => { it('fetches the MR with rolled-up pipeline status', async () => { getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' }) @@ -281,6 +381,34 @@ describe('gitlab client — MR operations', () => { ) }) + it('routes local WSL merge-request branch lookup through the selected distro', async () => { + getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' }) + glabExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify([ + { + iid: 12, + title: 'WSL branch', + state: 'opened', + sha: 'abc', + head_pipeline: { status: 'success' } + } + ]) + }) + + const mr = await getMergeRequestForBranch('/repo', 'feature/wsl', null, null, { + localGitExecOptions: { wslDistro: 'Ubuntu' } + }) + + expect(mr?.number).toBe(12) + expect(getProjectRefMock).toHaveBeenCalledWith('/repo', ['gitlab.com'], null, { + wslDistro: 'Ubuntu' + }) + expect(glabExecFileAsyncMock).toHaveBeenCalledWith(expect.any(Array), { + cwd: '/repo', + wslDistro: 'Ubuntu' + }) + }) + it('returns null for an empty / detached-HEAD branch arg', async () => { // Why: during a rebase the branch is empty — mirror github/getPRForBranch's // early return without calling glab. @@ -344,6 +472,51 @@ describe('gitlab client — MR operations', () => { ) }) + it('routes local WSL MR listing through project resolution and glab API options', async () => { + const localGitOptions = { wslDistro: 'Ubuntu' } + glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} }) + + await listMergeRequests('/repo', 'opened', 1, 20, undefined, undefined, null, localGitOptions) + + expect(resolveIssueSourceMock).toHaveBeenCalledWith( + '/repo', + undefined, + ['gitlab.com'], + null, + localGitOptions + ) + expect(glabApiWithHeadersMock).toHaveBeenCalledWith( + [ + 'projects/g%2Fp/merge_requests?page=1&per_page=20&order_by=updated_at&sort=desc&with_merge_status_recheck=false&state=opened' + ], + { cwd: '/repo', wslDistro: 'Ubuntu' } + ) + }) + + it('routes local WSL combined work-item listing through MR and issue glab options', async () => { + const localGitOptions = { wslDistro: 'Ubuntu' } + glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} }) + glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) + + await listWorkItems('/repo', 'opened', 1, 20, undefined, undefined, null, localGitOptions) + + expect(resolveIssueSourceMock).toHaveBeenCalledWith( + '/repo', + undefined, + ['gitlab.com'], + null, + localGitOptions + ) + expect(glabApiWithHeadersMock).toHaveBeenCalledWith( + expect.any(Array), + expect.objectContaining({ cwd: '/repo', wslDistro: 'Ubuntu' }) + ) + expect(glabExecFileAsyncMock).toHaveBeenCalledWith( + expect.any(Array), + expect.objectContaining({ cwd: '/repo', wslDistro: 'Ubuntu' }) + ) + }) + it("omits state when state='all'", async () => { glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} }) diff --git a/src/main/gitlab/client.test.ts b/src/main/gitlab/client.test.ts index e93fccdae1c..9ddfd9038e5 100644 --- a/src/main/gitlab/client.test.ts +++ b/src/main/gitlab/client.test.ts @@ -1,14 +1,19 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as GlUtils from './gl-utils' -const { glabExecFileAsyncMock, getGlabKnownHostsMock, acquireMock, releaseMock } = vi.hoisted( - () => ({ - glabExecFileAsyncMock: vi.fn(), - getGlabKnownHostsMock: vi.fn(), - acquireMock: vi.fn(), - releaseMock: vi.fn() - }) -) +const { + glabExecFileAsyncMock, + getGlabKnownHostsMock, + getProjectRefMock, + acquireMock, + releaseMock +} = vi.hoisted(() => ({ + glabExecFileAsyncMock: vi.fn(), + getGlabKnownHostsMock: vi.fn(), + getProjectRefMock: vi.fn(), + acquireMock: vi.fn(), + releaseMock: vi.fn() +})) vi.mock('./gl-utils', async () => { const actual = await vi.importActual('./gl-utils') @@ -16,6 +21,7 @@ vi.mock('./gl-utils', async () => { ...actual, glabExecFileAsync: glabExecFileAsyncMock, getGlabKnownHosts: getGlabKnownHostsMock, + getProjectRef: getProjectRefMock, acquire: acquireMock, release: releaseMock } @@ -27,10 +33,12 @@ describe('gitlab client — viewer & paste-URL lookup', () => { beforeEach(() => { glabExecFileAsyncMock.mockReset() getGlabKnownHostsMock.mockReset() + getProjectRefMock.mockReset() acquireMock.mockReset() releaseMock.mockReset() acquireMock.mockResolvedValue(undefined) getGlabKnownHostsMock.mockResolvedValue(['gitlab.com']) + getProjectRefMock.mockResolvedValue(null) }) describe('getAuthenticatedViewer', () => { @@ -135,6 +143,33 @@ describe('gitlab client — viewer & paste-URL lookup', () => { ) }) + it('routes local WSL pasted-project lookup through glab execution options', async () => { + const localGitOptions = { wslDistro: 'Ubuntu' } + glabExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + id: 201, + iid: 9, + title: 'WSL bug', + state: 'opened', + web_url: 'https://gitlab.com/g/p/-/issues/9' + }) + }) + + await getWorkItemByProjectRef( + '/repo', + { host: 'gitlab.com', path: 'g/p' }, + 9, + 'issue', + null, + localGitOptions + ) + + expect(glabExecFileAsyncMock).toHaveBeenCalledWith(['api', 'projects/g%2Fp/issues/9'], { + cwd: '/repo', + wslDistro: 'Ubuntu' + }) + }) + it('returns null when the API errors', async () => { glabExecFileAsyncMock.mockRejectedValueOnce(new Error('not found')) const item = await getWorkItemByProjectRef( @@ -190,6 +225,20 @@ describe('gitlab client — viewer & paste-URL lookup', () => { ) }) + it('routes local WSL todos through project resolution and glab execution options', async () => { + const localGitOptions = { wslDistro: 'Ubuntu' } + getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' }) + glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) + + await expect(listTodos('/repo', null, localGitOptions)).resolves.toEqual([]) + + expect(getProjectRefMock).toHaveBeenCalledWith('/repo', ['gitlab.com'], null, localGitOptions) + expect(glabExecFileAsyncMock).toHaveBeenCalledWith( + ['api', 'todos?state=pending&per_page=50'], + { cwd: '/repo', wslDistro: 'Ubuntu' } + ) + }) + it('coerces non-pending state values to pending (defensive)', async () => { // Why: we filter to state=pending in the request, but if a future // glab change leaks a different state through, the type's narrow diff --git a/src/main/gitlab/client.ts b/src/main/gitlab/client.ts index 12800f9329e..11ce0fe6796 100644 --- a/src/main/gitlab/client.ts +++ b/src/main/gitlab/client.ts @@ -38,9 +38,15 @@ import { parseGlabAuthStatusHosts, release, resolveIssueSource, + type LocalGitExecOptions, type ProjectRef } from './gl-utils' import type { IssueListState } from './issues' +import { + hasHostedReviewLocalGitOptions, + getHostedReviewLocalGitOptions, + type HostedReviewExecutionOptions +} from '../source-control/hosted-review-git-options' // Why: glab REST API addresses projects by URL-encoded path. Centralized // so call sites don't forget the slash escapes for nested groups. @@ -52,6 +58,14 @@ const GITLAB_RATE_LIMIT_CACHE_TTL_MS = 30_000 const GITLAB_RATE_LIMIT_CACHE_MAX_ENTRIES = 64 const gitLabRateLimitCache = new Map() +type HostedReviewLocalGitOptions = ReturnType + +function hostedReviewLocalGitOptionArgs( + options: HostedReviewExecutionOptions = {} +): [] | [HostedReviewLocalGitOptions] { + return hasHostedReviewLocalGitOptions(options) ? [getHostedReviewLocalGitOptions(options)] : [] +} + /** * Get the authenticated GitLab viewer. Mirrors getAuthenticatedViewer * from the GitHub client — returns null when glab is unavailable, the @@ -228,10 +242,16 @@ export async function getRateLimit(options?: { */ export async function getProjectSlug( repoPath: string, - connectionId?: string | null + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} ): Promise { const knownHosts = await getGlabKnownHosts() - return getProjectRef(repoPath, knownHosts, connectionId) + return getProjectRef( + repoPath, + knownHosts, + connectionId, + ...hostedReviewLocalGitOptionArgs(options) + ) } /** @@ -242,10 +262,13 @@ export async function getProjectSlug( export async function getMergeRequest( repoPath: string, iid: number, - connectionId?: string | null + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} ): Promise { const knownHosts = await getGlabKnownHosts() - const projectRef = await getProjectRef(repoPath, knownHosts, connectionId) + const localGitArgs = hostedReviewLocalGitOptionArgs(options) + const localGitOptions = localGitArgs[0] ?? {} + const projectRef = await getProjectRef(repoPath, knownHosts, connectionId, ...localGitArgs) await acquire() try { const args = projectRef @@ -255,7 +278,10 @@ export async function getMergeRequest( `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}` ] : ['mr', 'view', String(iid), '--output', 'json'] - const { stdout } = await glabExecFileAsync(args, glabRepoExecOptions(repoPath, connectionId)) + const { stdout } = await glabExecFileAsync( + args, + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) const data = JSON.parse(stdout) as Parameters[0] & { head_pipeline?: { status?: string } | null pipeline?: { status?: string } | null @@ -283,14 +309,17 @@ export async function getMergeRequestForBranch( repoPath: string, branch: string, linkedMRIid?: number | null, - connectionId?: string | null + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} ): Promise { const branchName = branch.replace(/^refs\/heads\//, '') if (!branchName && linkedMRIid == null) { return null } const knownHosts = await getGlabKnownHosts() - const projectRef = await getProjectRef(repoPath, knownHosts, connectionId) + const localGitArgs = hostedReviewLocalGitOptionArgs(options) + const localGitOptions = localGitArgs[0] ?? {} + const projectRef = await getProjectRef(repoPath, knownHosts, connectionId, ...localGitArgs) if (!projectRef) { return null } @@ -303,7 +332,7 @@ export async function getMergeRequestForBranch( ...glabHostnameArgs(projectRef, connectionId), `projects/${encodedProject(projectRef.path)}/merge_requests?source_branch=${encodeURIComponent(branchName)}&order_by=updated_at&sort=desc&per_page=1` ], - glabRepoExecOptions(repoPath, connectionId) + glabRepoExecOptions(repoPath, connectionId, localGitOptions) ) const data = JSON.parse(stdout) as (Parameters[0] & { head_pipeline?: { status?: string } | null @@ -329,7 +358,7 @@ export async function getMergeRequestForBranch( ...glabHostnameArgs(projectRef, connectionId), `projects/${encodedProject(projectRef.path)}/merge_requests/${linkedMRIid}` ], - glabRepoExecOptions(repoPath, connectionId) + glabRepoExecOptions(repoPath, connectionId, localGitOptions) ) const raw = JSON.parse(stdout) as Parameters[0] & { head_pipeline?: { status?: string } | null @@ -368,7 +397,8 @@ export async function listMergeRequests( perPage = 20, preference?: IssueSourcePreference, query?: string, - connectionId?: string | null + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} ): Promise { const knownHosts = await getGlabKnownHosts() // Why: MRs sit on `origin` in the fork model (the user's fork is where @@ -379,7 +409,8 @@ export async function listMergeRequests( repoPath, preference, knownHosts, - connectionId + connectionId, + localGitOptions ) if (!projectRef) { if (connectionId) { @@ -420,7 +451,7 @@ export async function listMergeRequests( 'desc', ...stateFlag ], - glabRepoExecOptions(repoPath, connectionId) + glabRepoExecOptions(repoPath, connectionId, localGitOptions) ) const data = JSON.parse(stdout) as Parameters[0][] return { @@ -459,7 +490,7 @@ export async function listMergeRequests( try { const { body, headers } = await glabApiWithHeaders( [...glabHostnameArgs(projectRef, connectionId), path], - glabRepoExecOptions(repoPath, connectionId) + glabRepoExecOptions(repoPath, connectionId, localGitOptions) ) const data = JSON.parse(body) as Parameters[0][] return { @@ -507,7 +538,8 @@ export async function getWorkItemByProjectRef( projectRef: ProjectRef, iid: number, type: 'issue' | 'mr', - connectionId?: string | null + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} ): Promise { await acquire() try { @@ -520,7 +552,7 @@ export async function getWorkItemByProjectRef( ...(projectRef.host ? ['--hostname', projectRef.host] : []), `projects/${encodedProject(projectRef.path)}/${resource}/${iid}` ], - glabRepoExecOptions(repoPath, connectionId) + glabRepoExecOptions(repoPath, connectionId, localGitOptions) ) const data = JSON.parse(stdout) if (type === 'mr') { @@ -557,7 +589,8 @@ export async function listWorkItems( perPage = 20, preference?: IssueSourcePreference, query?: string, - connectionId?: string | null + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} ): Promise> { const issueState = mrStateToIssueState(state) const knownHosts = await getGlabKnownHosts() @@ -565,7 +598,8 @@ export async function listWorkItems( repoPath, preference, knownHosts, - connectionId + connectionId, + localGitOptions ) if (!projectRef) { return { @@ -591,13 +625,31 @@ export async function listWorkItems( // raw issues API directly and run mapIssueToWorkItem against the // raw payload instead. const [mrs, issues] = await Promise.all([ - listMergeRequests(repoPath, state, page, perPage, preference, query, connectionId), + listMergeRequests( + repoPath, + state, + page, + perPage, + preference, + query, + connectionId, + localGitOptions + ), issueState === null ? Promise.resolve({ items: [] as GitLabWorkItem[], error: undefined as ClassifiedError | undefined }) - : fetchIssuesAsWorkItems(repoPath, projectRef, issueState, page, perPage, query, connectionId) + : fetchIssuesAsWorkItems( + repoPath, + projectRef, + issueState, + page, + perPage, + query, + connectionId, + localGitOptions + ) ]) const merged = [...mrs.items, ...issues.items].sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '') @@ -629,7 +681,8 @@ export async function fetchIssuesAsWorkItems( page: number, perPage: number, query?: string, - connectionId?: string | null + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} ): Promise<{ items: GitLabWorkItem[]; error: ClassifiedError | undefined }> { await acquire() try { @@ -641,7 +694,7 @@ export async function fetchIssuesAsWorkItems( ...glabHostnameArgs(projectRef, connectionId), `projects/${encodedProject(projectRef.path)}/issues?page=${page}&per_page=${perPage}&order_by=updated_at&sort=desc${stateParam}${searchParam}` ], - glabRepoExecOptions(repoPath, connectionId) + glabRepoExecOptions(repoPath, connectionId, localGitOptions) ) const data = JSON.parse(stdout) as Parameters[0][] return { @@ -671,9 +724,15 @@ export async function fetchIssuesAsWorkItems( */ export async function listTodos( repoPath: string, - connectionId?: string | null + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} ): Promise { - const projectRef = await getProjectRef(repoPath, await getGlabKnownHosts(), connectionId) + const projectRef = await getProjectRef( + repoPath, + await getGlabKnownHosts(), + connectionId, + localGitOptions + ) if (connectionId && !projectRef) { return [] } @@ -688,7 +747,7 @@ export async function listTodos( ...(projectRef ? glabHostnameArgs(projectRef, connectionId) : []), 'todos?state=pending&per_page=50' ], - glabRepoExecOptions(repoPath, connectionId) + glabRepoExecOptions(repoPath, connectionId, localGitOptions) ) type RESTTodo = { id?: number @@ -740,11 +799,20 @@ async function withProjectRef( connectionId: string | null | undefined, explicitProjectRef: ProjectRef | null | undefined, fn: (projectRef: ProjectRef, repoFlag: string) => Promise, - fallback: T + fallback: T, + localGitOptions: LocalGitExecOptions = {} ): Promise { const projectRef = explicitProjectRef ?? - (await resolveIssueSource(repoPath, preference, await getGlabKnownHosts(), connectionId)).source + ( + await resolveIssueSource( + repoPath, + preference, + await getGlabKnownHosts(), + connectionId, + localGitOptions + ) + ).source if (!projectRef) { return fallback } @@ -756,7 +824,8 @@ export async function closeMR( iid: number, preference?: IssueSourcePreference, connectionId?: string | null, - projectRef?: ProjectRef | null + projectRef?: ProjectRef | null, + localGitOptions: LocalGitExecOptions = {} ): Promise<{ ok: true } | { ok: false; error: string }> { return withProjectRef<{ ok: true } | { ok: false; error: string }>( repoPath, @@ -775,7 +844,7 @@ export async function closeMR( repoFlag, ...glabHostnameArgs(projectRef, connectionId) ], - glabRepoExecOptions(repoPath, connectionId) + glabRepoExecOptions(repoPath, connectionId, localGitOptions) ) return { ok: true } } catch (err) { @@ -791,7 +860,8 @@ export async function closeMR( release() } }, - { ok: false, error: 'Could not resolve GitLab project for this repository' } + { ok: false, error: 'Could not resolve GitLab project for this repository' }, + localGitOptions ) } @@ -800,7 +870,8 @@ export async function reopenMR( iid: number, preference?: IssueSourcePreference, connectionId?: string | null, - projectRef?: ProjectRef | null + projectRef?: ProjectRef | null, + localGitOptions: LocalGitExecOptions = {} ): Promise<{ ok: true } | { ok: false; error: string }> { return withProjectRef<{ ok: true } | { ok: false; error: string }>( repoPath, @@ -819,7 +890,7 @@ export async function reopenMR( repoFlag, ...glabHostnameArgs(projectRef, connectionId) ], - glabRepoExecOptions(repoPath, connectionId) + glabRepoExecOptions(repoPath, connectionId, localGitOptions) ) return { ok: true } } catch (err) { @@ -832,7 +903,8 @@ export async function reopenMR( release() } }, - { ok: false, error: 'Could not resolve GitLab project for this repository' } + { ok: false, error: 'Could not resolve GitLab project for this repository' }, + localGitOptions ) } @@ -842,7 +914,8 @@ export async function mergeMR( method: 'merge' | 'squash' | 'rebase' = 'merge', preference?: IssueSourcePreference, connectionId?: string | null, - projectRef?: ProjectRef | null + projectRef?: ProjectRef | null, + localGitOptions: LocalGitExecOptions = {} ): Promise<{ ok: true } | { ok: false; error: string }> { return withProjectRef<{ ok: true } | { ok: false; error: string }>( repoPath, @@ -868,7 +941,7 @@ export async function mergeMR( ...methodFlag, ...glabHostnameArgs(projectRef, connectionId) ], - glabRepoExecOptions(repoPath, connectionId) + glabRepoExecOptions(repoPath, connectionId, localGitOptions) ) return { ok: true } } catch (err) { @@ -877,7 +950,8 @@ export async function mergeMR( release() } }, - { ok: false, error: 'Could not resolve GitLab project for this repository' } + { ok: false, error: 'Could not resolve GitLab project for this repository' }, + localGitOptions ) } @@ -887,7 +961,8 @@ export async function addMRComment( body: string, preference?: IssueSourcePreference, connectionId?: string | null, - projectRef?: ProjectRef | null + projectRef?: ProjectRef | null, + localGitOptions: LocalGitExecOptions = {} ): Promise<{ ok: true; comment: MRComment } | { ok: false; error: string }> { return withProjectRef<{ ok: true; comment: MRComment } | { ok: false; error: string }>( repoPath, @@ -907,7 +982,7 @@ export async function addMRComment( '-f', `body=${body}` ], - glabRepoExecOptions(repoPath, connectionId) + glabRepoExecOptions(repoPath, connectionId, localGitOptions) ) const data = JSON.parse(stdout) as { id?: number @@ -933,7 +1008,8 @@ export async function addMRComment( release() } }, - { ok: false, error: 'Could not resolve GitLab project for this repository' } + { ok: false, error: 'Could not resolve GitLab project for this repository' }, + localGitOptions ) } @@ -943,7 +1019,8 @@ export async function addMRInlineComment( input: GitLabMRInlineCommentInput, preference?: IssueSourcePreference, connectionId?: string | null, - projectRef?: ProjectRef | null + projectRef?: ProjectRef | null, + localGitOptions: LocalGitExecOptions = {} ): Promise<{ ok: true; comment: MRComment } | { ok: false; error: string }> { return withProjectRef<{ ok: true; comment: MRComment } | { ok: false; error: string }>( repoPath, @@ -982,7 +1059,7 @@ export async function addMRInlineComment( '-f', `position[new_line]=${input.line}` ], - glabRepoExecOptions(repoPath, connectionId) + glabRepoExecOptions(repoPath, connectionId, localGitOptions) ) const data = JSON.parse(stdout) as { id?: string @@ -1018,7 +1095,8 @@ export async function addMRInlineComment( release() } }, - { ok: false, error: 'Could not resolve GitLab project for this repository' } + { ok: false, error: 'Could not resolve GitLab project for this repository' }, + localGitOptions ) } @@ -1029,7 +1107,8 @@ export async function resolveMRDiscussion( resolved: boolean, preference?: IssueSourcePreference, connectionId?: string | null, - projectRef?: ProjectRef | null + projectRef?: ProjectRef | null, + localGitOptions: LocalGitExecOptions = {} ): Promise { return withProjectRef( repoPath, @@ -1055,7 +1134,7 @@ export async function resolveMRDiscussion( '-f', `resolved=${resolved ? 'true' : 'false'}` ], - glabRepoExecOptions(repoPath, connectionId) + glabRepoExecOptions(repoPath, connectionId, localGitOptions) ) return { ok: true } } catch (err) { @@ -1065,7 +1144,8 @@ export async function resolveMRDiscussion( release() } }, - { ok: false, error: 'Could not resolve GitLab project for this repository' } + { ok: false, error: 'Could not resolve GitLab project for this repository' }, + localGitOptions ) } @@ -1117,7 +1197,8 @@ export async function updateMRReviewers( reviewerIds: number[], preference?: IssueSourcePreference, connectionId?: string | null, - projectRef?: ProjectRef | null + projectRef?: ProjectRef | null, + localGitOptions: LocalGitExecOptions = {} ): Promise { return withProjectRef( repoPath, @@ -1140,7 +1221,7 @@ export async function updateMRReviewers( `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}`, ...fields ], - glabRepoExecOptions(repoPath, connectionId) + glabRepoExecOptions(repoPath, connectionId, localGitOptions) ) const data = JSON.parse(stdout) as { reviewers?: Parameters[0][] } return { @@ -1156,7 +1237,8 @@ export async function updateMRReviewers( release() } }, - { ok: false, error: 'Could not resolve GitLab project for this repository' } + { ok: false, error: 'Could not resolve GitLab project for this repository' }, + localGitOptions ) } @@ -1165,7 +1247,8 @@ export async function getJobTrace( jobId: number, preference?: IssueSourcePreference, connectionId?: string | null, - projectRef?: ProjectRef | null + projectRef?: ProjectRef | null, + localGitOptions: LocalGitExecOptions = {} ): Promise { return withProjectRef( repoPath, @@ -1181,7 +1264,7 @@ export async function getJobTrace( ...glabHostnameArgs(projectRef, connectionId), `projects/${encodedProject(projectRef.path)}/jobs/${jobId}/trace` ], - glabRepoExecOptions(repoPath, connectionId) + glabRepoExecOptions(repoPath, connectionId, localGitOptions) ) return { ok: true, trace: stdout } } catch (err) { @@ -1191,7 +1274,8 @@ export async function getJobTrace( release() } }, - { ok: false, error: 'Could not resolve GitLab project for this repository' } + { ok: false, error: 'Could not resolve GitLab project for this repository' }, + localGitOptions ) } @@ -1200,7 +1284,8 @@ export async function retryJob( jobId: number, preference?: IssueSourcePreference, connectionId?: string | null, - projectRef?: ProjectRef | null + projectRef?: ProjectRef | null, + localGitOptions: LocalGitExecOptions = {} ): Promise { return withProjectRef( repoPath, @@ -1218,7 +1303,7 @@ export async function retryJob( 'POST', `projects/${encodedProject(projectRef.path)}/jobs/${jobId}/retry` ], - glabRepoExecOptions(repoPath, connectionId) + glabRepoExecOptions(repoPath, connectionId, localGitOptions) ) const trimmed = stdout.trim() return { @@ -1232,7 +1317,8 @@ export async function retryJob( release() } }, - { ok: false, error: 'Could not resolve GitLab project for this repository' } + { ok: false, error: 'Could not resolve GitLab project for this repository' }, + localGitOptions ) } @@ -1247,7 +1333,8 @@ export async function updateMR( }, preference?: IssueSourcePreference, connectionId?: string | null, - projectRef?: ProjectRef | null + projectRef?: ProjectRef | null, + localGitOptions: LocalGitExecOptions = {} ): Promise<{ ok: true } | { ok: false; error: string }> { return withProjectRef<{ ok: true } | { ok: false; error: string }>( repoPath, @@ -1289,7 +1376,7 @@ export async function updateMR( `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}`, ...fields.flatMap((field) => ['-f', field]) ], - glabRepoExecOptions(repoPath, connectionId) + glabRepoExecOptions(repoPath, connectionId, localGitOptions) ) return { ok: true } } catch (err) { @@ -1299,7 +1386,8 @@ export async function updateMR( release() } }, - { ok: false, error: 'Could not resolve GitLab project for this repository' } + { ok: false, error: 'Could not resolve GitLab project for this repository' }, + localGitOptions ) } diff --git a/src/main/gitlab/gitlab-project-ref-resolution.ts b/src/main/gitlab/gitlab-project-ref-resolution.ts new file mode 100644 index 00000000000..d414f640ccd --- /dev/null +++ b/src/main/gitlab/gitlab-project-ref-resolution.ts @@ -0,0 +1,282 @@ +import { gitExecFileAsync, glabExecFileAsync } from '../git/runner' +import type { IssueSourcePreference } from '../../shared/types' +import { getSshGitProvider } from '../providers/ssh-git-dispatch' +import { clearProjectRefInFlight, runProjectRefProbeOnce } from './project-ref-inflight' +import { + DEFAULT_GITLAB_HOSTS, + normalizeGitLabHost, + parseGitLabProjectRef, + parseRemoteProjectRefCandidate, + type ProjectRef +} from './project-ref-parser' + +export { DEFAULT_GITLAB_HOSTS, parseGitLabProjectRef } +export type { ProjectRef } + +export type LocalGitExecOptions = { + wslDistro?: string +} + +const PROJECT_REF_CACHE_MAX_ENTRIES = 512 +const projectRefCache = new Map() + +let knownHostsCache: readonly string[] | null = null + +/** @internal - exposed for tests only */ +export function _resetProjectRefCache(): void { + projectRefCache.clear() + clearProjectRefInFlight() +} + +/** @internal - exposed for tests only */ +export function _getProjectRefCacheSize(): number { + return projectRefCache.size +} + +/** @internal - exposed for tests only */ +export function _resetKnownHostsCache(): void { + knownHostsCache = null +} + +function rememberProjectRefCacheEntry(cacheKey: string, value: ProjectRef | null): void { + projectRefCache.set(cacheKey, value) + while (projectRefCache.size > PROJECT_REF_CACHE_MAX_ENTRIES) { + const oldestKey = projectRefCache.keys().next().value + if (oldestKey === undefined) { + return + } + projectRefCache.delete(oldestKey) + } +} + +export async function getProjectRefForRemote( + repoPath: string, + remoteName: string, + knownHosts: readonly string[] = DEFAULT_GITLAB_HOSTS, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const runtimeKey = connectionId ?? `local:${localGitOptions.wslDistro ?? 'host'}` + const cacheKey = `${runtimeKey}\0${repoPath}\0${remoteName}\0${knownHosts.join(',')}` + if (projectRefCache.has(cacheKey)) { + return projectRefCache.get(cacheKey)! + } + + return runProjectRefProbeOnce(cacheKey, () => + resolveProjectRefForRemote( + repoPath, + remoteName, + knownHosts, + connectionId, + cacheKey, + localGitOptions + ) + ) +} + +async function resolveProjectRefForRemote( + repoPath: string, + remoteName: string, + knownHosts: readonly string[], + connectionId: string | null | undefined, + cacheKey: string, + localGitOptions: LocalGitExecOptions +): Promise { + try { + const sshGitProvider = connectionId ? getSshGitProvider(connectionId) : null + if (connectionId && !sshGitProvider) { + return null + } + const { stdout } = sshGitProvider + ? await sshGitProvider.exec(['remote', 'get-url', remoteName], repoPath) + : await gitExecFileAsync(['remote', 'get-url', remoteName], { + cwd: repoPath, + ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) + }) + const result = parseGitLabProjectRef(stdout, knownHosts) + if (result) { + rememberProjectRefCacheEntry(cacheKey, result) + return result + } + const remoteCandidate = parseRemoteProjectRefCandidate(stdout) + if ( + remoteCandidate && + (await isGlabConfiguredForRemoteHost( + repoPath, + remoteCandidate, + connectionId, + localGitOptions + )) + ) { + rememberGlabKnownHost(remoteCandidate.host) + rememberProjectRefCacheEntry(cacheKey, remoteCandidate) + return remoteCandidate + } + } catch { + if (connectionId) { + return null + } + } + rememberProjectRefCacheEntry(cacheKey, null) + return null +} + +export async function getProjectRef( + repoPath: string, + knownHosts?: readonly string[], + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + return getProjectRefForRemote(repoPath, 'origin', knownHosts, connectionId, localGitOptions) +} + +export async function getIssueProjectRef( + repoPath: string, + knownHosts?: readonly string[], + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const upstream = await getProjectRefForRemote( + repoPath, + 'upstream', + knownHosts, + connectionId, + localGitOptions + ) + return ( + upstream ?? + getProjectRefForRemote(repoPath, 'origin', knownHosts, connectionId, localGitOptions) + ) +} + +export type ResolvedIssueSource = { + source: ProjectRef | null + /** True when explicit upstream is gone and resolver fell back to origin. */ + fellBack: boolean +} + +export async function resolveIssueSource( + repoPath: string, + preference: IssueSourcePreference | undefined, + knownHosts?: readonly string[], + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + if (preference === 'upstream') { + const upstream = await getProjectRefForRemote( + repoPath, + 'upstream', + knownHosts, + connectionId, + localGitOptions + ) + if (upstream) { + return { source: upstream, fellBack: false } + } + const origin = await getProjectRefForRemote( + repoPath, + 'origin', + knownHosts, + connectionId, + localGitOptions + ) + return { source: origin, fellBack: origin !== null } + } + if (preference === 'origin') { + return { + source: await getProjectRefForRemote( + repoPath, + 'origin', + knownHosts, + connectionId, + localGitOptions + ), + fellBack: false + } + } + return { + source: await getIssueProjectRef(repoPath, knownHosts, connectionId, localGitOptions), + fellBack: false + } +} + +export function glabRepoExecOptions( + repoPath: string, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): { cwd?: string; wslDistro?: string } { + return connectionId + ? {} + : { + cwd: repoPath, + ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) + } +} + +export function glabHostnameArgs( + projectRef: Pick | null | undefined, + connectionId?: string | null +): string[] { + return connectionId && projectRef?.host ? ['--hostname', projectRef.host] : [] +} + +function rememberGlabKnownHost(host: string): void { + const normalizedHost = normalizeGitLabHost(host) + if (!knownHostsCache || knownHostsCache.map(normalizeGitLabHost).includes(normalizedHost)) { + return + } + knownHostsCache = [...knownHostsCache, normalizedHost] +} + +async function isGlabConfiguredForRemoteHost( + repoPath: string, + projectRef: Pick, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + try { + const result = await glabExecFileAsync( + ['auth', 'status', '--hostname', projectRef.host], + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + return result !== undefined + } catch (error) { + const execLike = error as { stdout?: unknown; stderr?: unknown; message?: unknown } + const output = + [execLike.stdout, execLike.stderr, execLike.message] + .filter((value): value is string => typeof value === 'string' && value.trim().length > 0) + .join('\n') || String(error) + const hosts = parseGlabAuthStatusHosts(output).map(normalizeGitLabHost) + return hosts.includes(normalizeGitLabHost(projectRef.host)) + } +} + +export async function getGlabKnownHosts(): Promise { + if (knownHostsCache) { + return knownHostsCache + } + try { + const { stdout, stderr } = await glabExecFileAsync(['auth', 'status']) + const hosts = parseGlabAuthStatusHosts(`${stdout}\n${stderr}`) + knownHostsCache = Array.from(new Set([...DEFAULT_GITLAB_HOSTS, ...hosts])) + return knownHostsCache + } catch { + knownHostsCache = [...DEFAULT_GITLAB_HOSTS] + return knownHostsCache + } +} + +export function parseGlabAuthStatusHosts(output: string): string[] { + const hosts = new Set() + for (const m of output.matchAll(/logged in to ([a-zA-Z0-9.-]+)/gi)) { + hosts.add(m[1].toLowerCase()) + } + for (const line of output.split('\n')) { + const bareLine = line.trim() + const hostLine = bareLine.endsWith(':') ? bareLine.slice(0, -1) : bareLine + if (line === bareLine && /^[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?$/.test(hostLine)) { + hosts.add(hostLine.toLowerCase()) + } + } + return Array.from(hosts) +} diff --git a/src/main/gitlab/gl-utils.test.ts b/src/main/gitlab/gl-utils.test.ts index d48c28d2540..aae63efebe6 100644 --- a/src/main/gitlab/gl-utils.test.ts +++ b/src/main/gitlab/gl-utils.test.ts @@ -93,6 +93,38 @@ describe('gitlab project ref resolution', () => { }) }) + it('keeps local host and local WSL project-ref cache entries separate for the same path', async () => { + gitExecFileAsyncMock + .mockResolvedValueOnce({ stdout: 'git@gitlab.com:host/orca.git\n' }) + .mockResolvedValueOnce({ stdout: 'git@gitlab.com:wsl/orca.git\n' }) + + await expect(getProjectRef('/repo')).resolves.toEqual({ + host: 'gitlab.com', + path: 'host/orca' + }) + await expect(getProjectRef('/repo', undefined, null, { wslDistro: 'Ubuntu' })).resolves.toEqual( + { + host: 'gitlab.com', + path: 'wsl/orca' + } + ) + await expect(getProjectRef('/repo', undefined, null, { wslDistro: 'Ubuntu' })).resolves.toEqual( + { + host: 'gitlab.com', + path: 'wsl/orca' + } + ) + + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) + expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(1, ['remote', 'get-url', 'origin'], { + cwd: '/repo' + }) + expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(2, ['remote', 'get-url', 'origin'], { + cwd: '/repo', + wslDistro: 'Ubuntu' + }) + }) + it('coalesces concurrent missing remote probes for the same repo and remote', async () => { gitExecFileAsyncMock.mockImplementation(async () => { await Promise.resolve() diff --git a/src/main/gitlab/gl-utils.ts b/src/main/gitlab/gl-utils.ts index e65f6fecf28..33ec28b2de1 100644 --- a/src/main/gitlab/gl-utils.ts +++ b/src/main/gitlab/gl-utils.ts @@ -1,345 +1,58 @@ import { execFile } from 'child_process' import { promisify } from 'util' import { gitExecFileAsync, glabExecFileAsync } from '../git/runner' -import type { ClassifiedError, IssueSourcePreference } from '../../shared/types' -import { getSshGitProvider } from '../providers/ssh-git-dispatch' -import { clearProjectRefInFlight, runProjectRefProbeOnce } from './project-ref-inflight' -import { - DEFAULT_GITLAB_HOSTS, - normalizeGitLabHost, - parseGitLabProjectRef, - parseRemoteProjectRefCandidate, - type ProjectRef -} from './project-ref-parser' -// Why: legacy generic execFile wrapper — only used by callers that don't need -// WSL-aware routing. Repo-scoped callers should use glabExecFileAsync from -// the runner instead. +// Why: legacy generic execFile wrapper - only used by callers that don't need +// WSL-aware routing. Repo-scoped callers should use the runner exports below. export const execFileAsync = promisify(execFile) export { glabExecFileAsync, gitExecFileAsync } +export { classifyGlabError, classifyListIssuesError } from './glab-error-classification' +export { + DEFAULT_GITLAB_HOSTS, + _getProjectRefCacheSize, + _resetKnownHostsCache, + _resetProjectRefCache, + getGlabKnownHosts, + getIssueProjectRef, + getProjectRef, + getProjectRefForRemote, + glabHostnameArgs, + glabRepoExecOptions, + parseGlabAuthStatusHosts, + parseGitLabProjectRef, + resolveIssueSource +} from './gitlab-project-ref-resolution' +export type { + LocalGitExecOptions, + ProjectRef, + ResolvedIssueSource +} from './gitlab-project-ref-resolution' -// ── Concurrency limiter — max 4 parallel glab processes ───────────── -// Why: parallel to gh-utils' limiter. Separate state from the gh limiter -// because gh and glab are independent binaries; one provider's spawns -// shouldn't throttle the other's. Cap matches gh-utils for consistency. const MAX_CONCURRENT = 4 let running = 0 const queue: (() => void)[] = [] export function acquire(): Promise { if (running < MAX_CONCURRENT) { - running++ + running += 1 return Promise.resolve() } return new Promise((resolve) => queue.push(() => { - running++ + running += 1 resolve() }) ) } export function release(): void { - running-- + running -= 1 const next = queue.shift() if (next) { next() } } -// ── Error classification ───────────────────────────────────────────── -// Why: glab CLI surfaces API errors as unstructured stderr — same shape -// as gh. Map known GitLab patterns to typed errors so callers can show -// user-friendly messages. -export function classifyGlabError(stderr: string): ClassifiedError { - const s = stderr.toLowerCase() - if (s.includes('http 403') || s.includes('forbidden') || s.includes('insufficient_scope')) { - return { - type: 'permission_denied', - message: "You don't have permission to edit this issue. Check your GitLab token scopes." - } - } - if (s.includes('http 404') || s.includes('project not found')) { - return { type: 'not_found', message: 'Issue not found — it may have been deleted.' } - } - if (s.includes('http 422') || s.includes('unprocessable')) { - return { type: 'validation_error', message: `Invalid update — ${stderr.trim()}` } - } - // Why: GitLab returns 429 for rate limit; gh's "rate limit" stderr substring - // also fires through the user-mode token bucket. Cover both. - if (s.includes('rate limit') || s.includes('http 429')) { - return { - type: 'rate_limited', - message: 'GitLab rate limit hit. Try again in a few minutes.' - } - } - if ( - s.includes('timeout') || - s.includes('no such host') || - s.includes('network') || - s.includes('could not resolve host') - ) { - return { type: 'network_error', message: 'Network error — check your connection.' } - } - return { type: 'unknown', message: `Failed to update issue: ${stderr.trim()}` } -} - -// Why: classifyGlabError's copy is phrased for edit/update operations; -// listIssues is a read op. Rewrite the message for read contexts while -// keeping the typed classification intact for callers/telemetry. -export function classifyListIssuesError(stderr: string): ClassifiedError { - const c = classifyGlabError(stderr) - const trimmed = stderr.trim() - // Exhaustive map so newly added error types surface as a TS error here - // rather than silently falling through to edit-phrased copy. - const readMessages: Record = { - permission_denied: - "You don't have permission to read issues for this project. Check your GitLab token scopes.", - not_found: 'Project not found.', - issues_disabled: 'Issues are disabled on this project.', - validation_error: `Invalid request — ${trimmed}`, - rate_limited: 'GitLab rate limit hit. Try again in a few minutes.', - network_error: 'Network error — check your connection.', - unknown: `Failed to load issues: ${trimmed}` - } - return { type: c.type, message: readMessages[c.type] } -} - -export { DEFAULT_GITLAB_HOSTS, parseGitLabProjectRef } -export type { ProjectRef } - -const PROJECT_REF_CACHE_MAX_ENTRIES = 512 -const projectRefCache = new Map() - -/** @internal — exposed for tests only */ -export function _resetProjectRefCache(): void { - projectRefCache.clear() - clearProjectRefInFlight() -} - -/** @internal — exposed for tests only */ -export function _getProjectRefCacheSize(): number { - return projectRefCache.size -} - -function rememberProjectRefCacheEntry(cacheKey: string, value: ProjectRef | null): void { - projectRefCache.set(cacheKey, value) - while (projectRefCache.size > PROJECT_REF_CACHE_MAX_ENTRIES) { - const oldestKey = projectRefCache.keys().next().value - if (oldestKey === undefined) { - return - } - projectRefCache.delete(oldestKey) - } -} - -export async function getProjectRefForRemote( - repoPath: string, - remoteName: string, - knownHosts: readonly string[] = DEFAULT_GITLAB_HOSTS, - connectionId?: string | null -): Promise { - const cacheKey = `${connectionId ?? 'local'}\0${repoPath}\0${remoteName}\0${knownHosts.join(',')}` - if (projectRefCache.has(cacheKey)) { - return projectRefCache.get(cacheKey)! - } - - // Why: issue/PR refresh and repo metadata can ask for the same missing - // upstream concurrently. Coalesce the subprocess before caching the result. - return runProjectRefProbeOnce(cacheKey, () => - resolveProjectRefForRemote(repoPath, remoteName, knownHosts, connectionId, cacheKey) - ) -} - -async function resolveProjectRefForRemote( - repoPath: string, - remoteName: string, - knownHosts: readonly string[], - connectionId: string | null | undefined, - cacheKey: string -): Promise { - try { - const sshGitProvider = connectionId ? getSshGitProvider(connectionId) : null - if (connectionId && !sshGitProvider) { - // Why: mobile can attempt GitLab loads before the SSH tunnel is ready. - // Caching that transient state would poison later loads after connect. - return null - } - const { stdout } = sshGitProvider - ? await sshGitProvider.exec(['remote', 'get-url', remoteName], repoPath) - : await gitExecFileAsync(['remote', 'get-url', remoteName], { cwd: repoPath }) - const result = parseGitLabProjectRef(stdout, knownHosts) - if (result) { - rememberProjectRefCacheEntry(cacheKey, result) - return result - } - const remoteCandidate = parseRemoteProjectRefCandidate(stdout) - if ( - remoteCandidate && - (await isGlabConfiguredForRemoteHost(repoPath, remoteCandidate, connectionId)) - ) { - // Why: `glab auth status` is process-global and can be stale or formatted - // differently across versions; the origin host itself is the durable repo context. - rememberGlabKnownHost(remoteCandidate.host) - rememberProjectRefCacheEntry(cacheKey, remoteCandidate) - return remoteCandidate - } - } catch { - if (connectionId) { - // Why: remote SSH failures are often transient tunnel/process errors. - // Do not cache them as "not a GitLab repo" for the rest of the session. - return null - } - // ignore — non-GitLab remote or no remote configured - } - rememberProjectRefCacheEntry(cacheKey, null) - return null -} - -export async function getProjectRef( - repoPath: string, - knownHosts?: readonly string[], - connectionId?: string | null -): Promise { - return getProjectRefForRemote(repoPath, 'origin', knownHosts, connectionId) -} - -export async function getIssueProjectRef( - repoPath: string, - knownHosts?: readonly string[], - connectionId?: string | null -): Promise { - const upstream = await getProjectRefForRemote(repoPath, 'upstream', knownHosts, connectionId) - if (upstream) { - return upstream - } - return getProjectRefForRemote(repoPath, 'origin', knownHosts, connectionId) -} - -export type ResolvedIssueSource = { - source: ProjectRef | null - /** True when the user preferred `upstream` but the upstream remote is no - * longer configured and the resolver fell back to origin. */ - fellBack: boolean -} - -/** - * Resolve the issue source for a repo honoring the user's per-repo - * preference. Mirrors `resolveIssueSource` in gh-utils — the upstream/ - * origin/auto semantics are git-remote concepts, not GitHub-specific. - */ -export async function resolveIssueSource( - repoPath: string, - preference: IssueSourcePreference | undefined, - knownHosts?: readonly string[], - connectionId?: string | null -): Promise { - if (preference === 'upstream') { - const upstream = await getProjectRefForRemote(repoPath, 'upstream', knownHosts, connectionId) - if (upstream) { - return { source: upstream, fellBack: false } - } - const origin = await getProjectRefForRemote(repoPath, 'origin', knownHosts, connectionId) - return { source: origin, fellBack: origin !== null } - } - if (preference === 'origin') { - return { - source: await getProjectRefForRemote(repoPath, 'origin', knownHosts, connectionId), - fellBack: false - } - } - return { source: await getIssueProjectRef(repoPath, knownHosts, connectionId), fellBack: false } -} - -export function glabRepoExecOptions( - repoPath: string, - connectionId?: string | null -): { cwd?: string } { - return connectionId ? {} : { cwd: repoPath } -} - -export function glabHostnameArgs( - projectRef: Pick | null | undefined, - connectionId?: string | null -): string[] { - // Why: local glab commands can infer host from cwd; SSH-backed calls have - // no local cwd, so self-hosted instances need an explicit hostname. - return connectionId && projectRef?.host ? ['--hostname', projectRef.host] : [] -} - -// ── Known-hosts discovery via `glab auth status` ──────────────────── -// Why: glab supports multiple hosts (gitlab.com plus self-hosted). The -// authoritative list of "what counts as GitLab" from the user's POV is -// "what hosts have I authenticated with". Parse hostnames out of -// `glab auth status` output and cache the result process-wide. - -let knownHostsCache: readonly string[] | null = null - -function rememberGlabKnownHost(host: string): void { - const normalizedHost = normalizeGitLabHost(host) - if (!knownHostsCache || knownHostsCache.map(normalizeGitLabHost).includes(normalizedHost)) { - return - } - knownHostsCache = [...knownHostsCache, normalizedHost] -} - -async function isGlabConfiguredForRemoteHost( - repoPath: string, - projectRef: Pick, - connectionId?: string | null -): Promise { - try { - const result = await glabExecFileAsync( - ['auth', 'status', '--hostname', projectRef.host], - glabRepoExecOptions(repoPath, connectionId) - ) - return result !== undefined - } catch (error) { - const execLike = error as { stdout?: unknown; stderr?: unknown; message?: unknown } - const output = - [execLike.stdout, execLike.stderr, execLike.message] - .filter((value): value is string => typeof value === 'string' && value.trim().length > 0) - .join('\n') || String(error) - const hosts = parseGlabAuthStatusHosts(output).map(normalizeGitLabHost) - return hosts.includes(normalizeGitLabHost(projectRef.host)) - } -} - -/** @internal — exposed for tests only */ -export function _resetKnownHostsCache(): void { - knownHostsCache = null -} - -export async function getGlabKnownHosts(): Promise { - if (knownHostsCache) { - return knownHostsCache - } - try { - const { stdout, stderr } = await glabExecFileAsync(['auth', 'status']) - // Why: glab writes auth status to stderr in some versions, stdout in - // others. Concatenate so the parser sees both. - const hosts = parseGlabAuthStatusHosts(`${stdout}\n${stderr}`) - // Always include gitlab.com so a fresh-install user with no auth - // still recognizes the canonical host. - const merged = Array.from(new Set([...DEFAULT_GITLAB_HOSTS, ...hosts])) - knownHostsCache = merged - return merged - } catch { - // Auth check failed (glab not installed, no auth, etc.) — fall back - // to the canonical default. The caller will hit the auth error on - // the first real request anyway. - knownHostsCache = [...DEFAULT_GITLAB_HOSTS] - return knownHostsCache - } -} - -// ── Paginated `glab api -i` helper ────────────────────────────────── -// Why: GitLab returns total counts via response headers (X-Total, -// X-Total-Pages) on paginated REST endpoints. `glab api` discards -// headers by default; passing `-i` includes the raw HTTP response -// before the JSON body. Parse out the headers + body so callers can -// surface "Page X of Y" UIs without hand-rolling a second count call. - export type GlabApiResponse = { body: string headers: Record @@ -353,10 +66,8 @@ export async function glabApiWithHeaders( return parseGlabApiResponse(stdout) } -/** @internal — exported for tests. */ +/** @internal - exported for tests. */ export function parseGlabApiResponse(stdout: string): GlabApiResponse { - // Why: response is `HTTP/x.y status\nHeader: val\n…\n\n`. - // Match the first blank line (CRLF or LF) as the boundary. const sepMatch = stdout.match(/\r?\n\r?\n/) if (!sepMatch || sepMatch.index === undefined) { return { body: stdout, headers: {} } @@ -364,7 +75,6 @@ export function parseGlabApiResponse(stdout: string): GlabApiResponse { const headerBlock = stdout.slice(0, sepMatch.index) const body = stdout.slice(sepMatch.index + sepMatch[0].length) const headers: Record = {} - // Skip the status line (HTTP/x.y …) and parse the rest as key: value. const lines = headerBlock.split(/\r?\n/) for (const line of lines) { const m = line.match(/^([A-Za-z][A-Za-z0-9-]*):\s*(.*)$/) @@ -374,24 +84,3 @@ export function parseGlabApiResponse(stdout: string): GlabApiResponse { } return { body, headers } } - -// Why: glab auth status output is human-formatted and varies across versions. -// Two patterns observed in the wild: -// 1) "✓ Logged in to gitlab.com as " -// 2) "gitlab.example.com:" header followed by indented status lines -// Match both, dedupe, lowercase. Best-effort — anything that looks like a -// hostname. -export function parseGlabAuthStatusHosts(output: string): string[] { - const hosts = new Set() - for (const m of output.matchAll(/logged in to ([a-zA-Z0-9.-]+)/gi)) { - hosts.add(m[1].toLowerCase()) - } - for (const line of output.split('\n')) { - const bareLine = line.trim() - const hostLine = bareLine.endsWith(':') ? bareLine.slice(0, -1) : bareLine - if (line === bareLine && /^[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?$/.test(hostLine)) { - hosts.add(hostLine.toLowerCase()) - } - } - return Array.from(hosts) -} diff --git a/src/main/gitlab/glab-error-classification.ts b/src/main/gitlab/glab-error-classification.ts new file mode 100644 index 00000000000..e1f740749e0 --- /dev/null +++ b/src/main/gitlab/glab-error-classification.ts @@ -0,0 +1,52 @@ +import type { ClassifiedError } from '../../shared/types' + +// Why: glab CLI surfaces API errors as unstructured stderr. Map known +// patterns to typed errors so callers can show user-friendly messages. +export function classifyGlabError(stderr: string): ClassifiedError { + const s = stderr.toLowerCase() + if (s.includes('http 403') || s.includes('forbidden') || s.includes('insufficient_scope')) { + return { + type: 'permission_denied', + message: "You don't have permission to edit this issue. Check your GitLab token scopes." + } + } + if (s.includes('http 404') || s.includes('project not found')) { + return { type: 'not_found', message: 'Issue not found — it may have been deleted.' } + } + if (s.includes('http 422') || s.includes('unprocessable')) { + return { type: 'validation_error', message: `Invalid update — ${stderr.trim()}` } + } + if (s.includes('rate limit') || s.includes('http 429')) { + return { + type: 'rate_limited', + message: 'GitLab rate limit hit. Try again in a few minutes.' + } + } + if ( + s.includes('timeout') || + s.includes('no such host') || + s.includes('network') || + s.includes('could not resolve host') + ) { + return { type: 'network_error', message: 'Network error — check your connection.' } + } + return { type: 'unknown', message: `Failed to update issue: ${stderr.trim()}` } +} + +// Why: classifyGlabError's copy is phrased for edit/update operations; list +// issues is a read op, so rewrite messages for read-context banners. +export function classifyListIssuesError(stderr: string): ClassifiedError { + const c = classifyGlabError(stderr) + const trimmed = stderr.trim() + const readMessages: Record = { + permission_denied: + "You don't have permission to read issues for this project. Check your GitLab token scopes.", + not_found: 'Project not found.', + issues_disabled: 'Issues are disabled on this project.', + validation_error: `Invalid request — ${trimmed}`, + rate_limited: 'GitLab rate limit hit. Try again in a few minutes.', + network_error: 'Network error — check your connection.', + unknown: `Failed to load issues: ${trimmed}` + } + return { type: c.type, message: readMessages[c.type] } +} diff --git a/src/main/gitlab/issues.test.ts b/src/main/gitlab/issues.test.ts index 9fc2303eccc..8095288cbf6 100644 --- a/src/main/gitlab/issues.test.ts +++ b/src/main/gitlab/issues.test.ts @@ -76,6 +76,76 @@ describe('gitlab issue operations', () => { ) }) + it('routes local WSL issue operations through project resolution and glab execution options', async () => { + const localGitOptions = { wslDistro: 'Ubuntu' } + getIssueProjectRefMock.mockResolvedValue({ host: 'gitlab.com', path: 'stablyai/orca' }) + resolveIssueSourceMock.mockResolvedValue({ + source: { host: 'gitlab.com', path: 'stablyai/orca' }, + fellBack: false + }) + glabExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + iid: 923, + title: 'Use WSL', + state: 'opened', + web_url: 'https://gitlab.com/stablyai/orca/-/issues/923', + labels: [] + }) + }) + .mockResolvedValueOnce({ stdout: '[]' }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + iid: 924, + web_url: 'https://gitlab.com/stablyai/orca/-/issues/924' + }) + }) + .mockResolvedValueOnce({ stdout: '{}' }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + id: 1, + author: { username: 'octo', avatar_url: '', state: 'active' }, + body: 'Comment', + created_at: '2026-06-16T00:00:00.000Z' + }) + }) + .mockResolvedValueOnce({ stdout: 'bug\nfrontend\n' }) + .mockResolvedValueOnce({ stdout: '{"id":1,"username":"octo","avatar_url":""}\n' }) + + await getIssue('/repo-root', 923, null, localGitOptions) + await listIssues('/repo-root', 5, undefined, 'opened', undefined, null, localGitOptions) + await createIssue('/repo-root', 'New issue', 'Body', undefined, null, localGitOptions) + await updateIssue( + '/repo-root', + 923, + { body: 'Updated' }, + undefined, + null, + null, + localGitOptions + ) + await addIssueComment('/repo-root', 923, 'Comment', undefined, null, null, localGitOptions) + await listLabels('/repo-root', undefined, null, localGitOptions) + await listAssignableUsers('/repo-root', undefined, null, localGitOptions) + + expect(getIssueProjectRefMock).toHaveBeenCalledWith( + '/repo-root', + ['gitlab.com'], + null, + localGitOptions + ) + expect(resolveIssueSourceMock).toHaveBeenCalledWith( + '/repo-root', + undefined, + ['gitlab.com'], + null, + localGitOptions + ) + expect(glabExecFileAsyncMock.mock.calls.every((call) => call[1]?.wslDistro === 'Ubuntu')).toBe( + true + ) + }) + it('encodes nested group paths', async () => { getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', diff --git a/src/main/gitlab/issues.ts b/src/main/gitlab/issues.ts index b2ced1e3522..bc1bf2cc297 100644 --- a/src/main/gitlab/issues.ts +++ b/src/main/gitlab/issues.ts @@ -13,7 +13,7 @@ import type { } from '../../shared/types' import { mapGitLabIssueInfo } from './mappers' // prettier-ignore -import { glabExecFileAsync, acquire, release, getIssueProjectRef, resolveIssueSource, classifyGlabError, classifyListIssuesError, getGlabKnownHosts, glabRepoExecOptions, glabHostnameArgs, type ProjectRef } from './gl-utils' +import { glabExecFileAsync, acquire, release, getIssueProjectRef, resolveIssueSource, classifyGlabError, classifyListIssuesError, getGlabKnownHosts, glabRepoExecOptions, glabHostnameArgs, type LocalGitExecOptions, type ProjectRef } from './gl-utils' // Why: parallel to GitHub's IssueListResult — distinguishes a successful- // empty listing from a failed fetch. @@ -41,10 +41,11 @@ function encodedProject(projectPath: string): string { export async function getIssue( repoPath: string, issueNumber: number, - connectionId?: string | null + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} ): Promise { const knownHosts = await getGlabKnownHosts() - const projectRef = await getIssueProjectRef(repoPath, knownHosts, connectionId) + const projectRef = await getIssueProjectRef(repoPath, knownHosts, connectionId, localGitOptions) await acquire() try { if (projectRef) { @@ -54,7 +55,7 @@ export async function getIssue( ...glabHostnameArgs(projectRef, connectionId), `projects/${encodedProject(projectRef.path)}/issues/${issueNumber}` ], - glabRepoExecOptions(repoPath, connectionId) + glabRepoExecOptions(repoPath, connectionId, localGitOptions) ) const data = JSON.parse(stdout) return mapGitLabIssueInfo(data) @@ -62,7 +63,7 @@ export async function getIssue( // Fallback for non-GitLab remotes — let glab infer the project from cwd. const { stdout } = await glabExecFileAsync( ['issue', 'view', String(issueNumber), '--output', 'json'], - glabRepoExecOptions(repoPath, connectionId) + glabRepoExecOptions(repoPath, connectionId, localGitOptions) ) const data = JSON.parse(stdout) return mapGitLabIssueInfo(data) @@ -89,14 +90,16 @@ export async function listIssues( preference?: IssueSourcePreference, state: IssueListState = 'opened', assignee?: string, - connectionId?: string | null + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} ): Promise { const knownHosts = await getGlabKnownHosts() const { source: projectRef } = await resolveIssueSource( repoPath, preference, knownHosts, - connectionId + connectionId, + localGitOptions ) await acquire() try { @@ -109,7 +112,7 @@ export async function listIssues( ...glabHostnameArgs(projectRef, connectionId), `projects/${encodedProject(projectRef.path)}/issues?per_page=${limit}&order_by=updated_at&sort=desc${stateParam}${scopeParam}` ], - glabRepoExecOptions(repoPath, connectionId) + glabRepoExecOptions(repoPath, connectionId, localGitOptions) ) const data = JSON.parse(stdout) as Record[] // Why: GitLab's project issues endpoint returns true issues only @@ -138,7 +141,7 @@ export async function listIssues( ...stateFlag, ...assigneeFlag ], - glabRepoExecOptions(repoPath, connectionId) + glabRepoExecOptions(repoPath, connectionId, localGitOptions) ) const data = JSON.parse(stdout) as unknown[] return { @@ -164,7 +167,8 @@ export async function createIssue( title: string, body: string, preference?: IssueSourcePreference, - connectionId?: string | null + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} ): Promise<{ ok: true; number: number; url: string } | { ok: false; error: string }> { const trimmedTitle = title.trim() if (!trimmedTitle) { @@ -175,7 +179,8 @@ export async function createIssue( repoPath, preference, knownHosts, - connectionId + connectionId, + localGitOptions ) if (!projectRef) { return { @@ -198,7 +203,7 @@ export async function createIssue( // Why: GitLab uses `description` (not `body`) for issue text. `description=${body}` ], - glabRepoExecOptions(repoPath, connectionId) + glabRepoExecOptions(repoPath, connectionId, localGitOptions) ) const data = JSON.parse(stdout) as { iid?: number; web_url?: string; url?: string } if (typeof data.iid !== 'number') { @@ -230,11 +235,20 @@ export async function updateIssue( updates: GitLabIssueUpdate, preference?: IssueSourcePreference, connectionId?: string | null, - projectRefOverride?: ProjectRef | null + projectRefOverride?: ProjectRef | null, + localGitOptions: LocalGitExecOptions = {} ): Promise<{ ok: true } | { ok: false; error: string }> { const projectRef = projectRefOverride ?? - (await resolveIssueSource(repoPath, preference, await getGlabKnownHosts(), connectionId)).source + ( + await resolveIssueSource( + repoPath, + preference, + await getGlabKnownHosts(), + connectionId, + localGitOptions + ) + ).source if (!projectRef) { return { ok: false, @@ -259,7 +273,7 @@ export async function updateIssue( repoFlag, ...glabHostnameArgs(projectRef, connectionId) ], - glabRepoExecOptions(repoPath, connectionId) + glabRepoExecOptions(repoPath, connectionId, localGitOptions) ) } catch (err) { const stderr = err instanceof Error ? err.message : String(err) @@ -285,7 +299,7 @@ export async function updateIssue( '-f', `description=${updates.body}` ], - glabRepoExecOptions(repoPath, connectionId) + glabRepoExecOptions(repoPath, connectionId, localGitOptions) ) } catch (err) { const stderr = err instanceof Error ? err.message : String(err) @@ -330,7 +344,10 @@ export async function updateIssue( if (hasEditArgs) { await acquire() try { - await glabExecFileAsync(editArgs, glabRepoExecOptions(repoPath, connectionId)) + await glabExecFileAsync( + editArgs, + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) } catch (err) { const stderr = err instanceof Error ? err.message : String(err) errors.push(classifyGlabError(stderr).message) @@ -355,11 +372,20 @@ export async function addIssueComment( body: string, preference?: IssueSourcePreference, connectionId?: string | null, - projectRefOverride?: ProjectRef | null + projectRefOverride?: ProjectRef | null, + localGitOptions: LocalGitExecOptions = {} ): Promise { const projectRef = projectRefOverride ?? - (await resolveIssueSource(repoPath, preference, await getGlabKnownHosts(), connectionId)).source + ( + await resolveIssueSource( + repoPath, + preference, + await getGlabKnownHosts(), + connectionId, + localGitOptions + ) + ).source if (!projectRef) { return { ok: false, @@ -378,7 +404,7 @@ export async function addIssueComment( '-f', `body=${body}` ], - glabRepoExecOptions(repoPath, connectionId) + glabRepoExecOptions(repoPath, connectionId, localGitOptions) ) const data = JSON.parse(stdout) as { id?: number @@ -410,14 +436,16 @@ export async function addIssueComment( export async function listLabels( repoPath: string, preference?: IssueSourcePreference, - connectionId?: string | null + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} ): Promise { const knownHosts = await getGlabKnownHosts() const { source: projectRef } = await resolveIssueSource( repoPath, preference, knownHosts, - connectionId + connectionId, + localGitOptions ) if (!projectRef) { return [] @@ -433,7 +461,7 @@ export async function listLabels( '--jq', '.[].name' ], - glabRepoExecOptions(repoPath, connectionId) + glabRepoExecOptions(repoPath, connectionId, localGitOptions) ) return stdout .trim() @@ -449,14 +477,16 @@ export async function listLabels( export async function listAssignableUsers( repoPath: string, preference?: IssueSourcePreference, - connectionId?: string | null + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} ): Promise { const knownHosts = await getGlabKnownHosts() const { source: projectRef } = await resolveIssueSource( repoPath, preference, knownHosts, - connectionId + connectionId, + localGitOptions ) if (!projectRef) { return [] @@ -476,7 +506,7 @@ export async function listAssignableUsers( '--jq', '.[] | {id, username, name, avatar_url, state}' ], - glabRepoExecOptions(repoPath, connectionId) + glabRepoExecOptions(repoPath, connectionId, localGitOptions) ) type RESTMember = { id?: number diff --git a/src/main/gitlab/merge-request-creation-lookup.ts b/src/main/gitlab/merge-request-creation-lookup.ts new file mode 100644 index 00000000000..e5c8303ce1a --- /dev/null +++ b/src/main/gitlab/merge-request-creation-lookup.ts @@ -0,0 +1,87 @@ +import { + getHostedReviewLocalGitOptions, + type HostedReviewExecutionOptions +} from '../source-control/hosted-review-git-options' +import { + glabExecFileAsync, + glabHostnameArgs, + glabRepoExecOptions, + type ProjectRef +} from './gl-utils' + +export function parseMergeRequestPayload(stdout: string): { number: number; url: string } | null { + const trimmed = stdout.trim() + if (!trimmed) { + return null + } + try { + const parsed = JSON.parse(trimmed) as { + iid?: unknown + number?: unknown + web_url?: unknown + webUrl?: unknown + url?: unknown + } + const number = Number(parsed.iid ?? parsed.number) + const url = + typeof parsed.web_url === 'string' + ? parsed.web_url.trim() + : typeof parsed.webUrl === 'string' + ? parsed.webUrl.trim() + : typeof parsed.url === 'string' + ? parsed.url.trim() + : '' + if (Number.isInteger(number) && number > 0 && url) { + return { number, url } + } + } catch { + // Fall through to URL parsing for glab's normal text output. + } + const urlMatch = trimmed.match(/https?:\/\/[^\s]+\/-\/merge_requests\/(\d+)/) + if (!urlMatch) { + return null + } + return { number: Number(urlMatch[1]), url: urlMatch[0] } +} + +export async function findOpenMRByHeadBase(args: { + repoPath: string + projectRef: ProjectRef + head: string + base: string + connectionId?: string | null + options?: HostedReviewExecutionOptions +}): Promise<{ number: number; url: string } | null> { + const { stdout } = await glabExecFileAsync( + [ + 'mr', + 'list', + '-R', + args.projectRef.path, + '--source-branch', + args.head, + '--target-branch', + args.base, + '--per-page', + '2', + '--output', + 'json', + ...glabHostnameArgs(args.projectRef, args.connectionId) + ], + { + ...glabRepoExecOptions(args.repoPath, args.connectionId), + ...(args.connectionId ? {} : getHostedReviewLocalGitOptions(args.options)) + } + ) + const list = JSON.parse(stdout) as { + iid?: number + number?: number + web_url?: string + webUrl?: string + url?: string + }[] + if (list.length !== 1) { + return null + } + return parseMergeRequestPayload(JSON.stringify(list[0])) +} diff --git a/src/main/gitlab/merge-request-creation.test.ts b/src/main/gitlab/merge-request-creation.test.ts index 742413c080a..cd1b5de9882 100644 --- a/src/main/gitlab/merge-request-creation.test.ts +++ b/src/main/gitlab/merge-request-creation.test.ts @@ -101,6 +101,39 @@ describe('createGitLabMergeRequest', () => { expect(releaseMock).toHaveBeenCalledOnce() }) + it('runs local WSL project merge request creation through the selected distro', async () => { + glabExecFileAsyncMock.mockResolvedValueOnce({ + stdout: 'https://gitlab.com/acme/widgets/-/merge_requests/43\n', + stderr: '' + }) + + await expect( + createGitLabMergeRequest( + '/repo-root', + { + provider: 'gitlab', + base: 'main', + head: 'feature/wsl-create-mr', + title: 'WSL Create MR' + }, + null, + { localGitExecOptions: { wslDistro: 'Ubuntu' } } + ) + ).resolves.toEqual({ + ok: true, + number: 43, + url: 'https://gitlab.com/acme/widgets/-/merge_requests/43' + }) + + const [, options] = glabExecFileAsyncMock.mock.calls[0] + expect(options).toMatchObject({ + cwd: '/repo-root', + wslDistro: 'Ubuntu', + timeout: 60_000, + idempotent: false + }) + }) + it('creates SSH-backed merge requests without using the remote path as a local cwd', async () => { glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify({ diff --git a/src/main/gitlab/merge-request-creation.ts b/src/main/gitlab/merge-request-creation.ts index 9dee1b66e0d..7d1980f834b 100644 --- a/src/main/gitlab/merge-request-creation.ts +++ b/src/main/gitlab/merge-request-creation.ts @@ -7,15 +7,20 @@ import { } from '../../shared/hosted-review-refs' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { joinWorktreeRelativePath } from '../runtime/runtime-relative-paths' +import { + getHostedReviewLocalGitOptions, + hasHostedReviewLocalGitOptions, + type HostedReviewExecutionOptions +} from '../source-control/hosted-review-git-options' import { getProjectSlug } from './client' import { acquire, glabExecFileAsync, glabHostnameArgs, glabRepoExecOptions, - release, - type ProjectRef + release } from './gl-utils' +import { findOpenMRByHeadBase, parseMergeRequestPayload } from './merge-request-creation-lookup' function execErrorMessage(error: unknown): string { if (error instanceof Error) { @@ -77,77 +82,10 @@ function classifyCreateMRError(error: unknown): CreateHostedReviewResult { } } -function parseMergeRequestPayload(stdout: string): { number: number; url: string } | null { - const trimmed = stdout.trim() - if (!trimmed) { - return null - } - try { - const parsed = JSON.parse(trimmed) as { - iid?: unknown - number?: unknown - web_url?: unknown - webUrl?: unknown - url?: unknown - } - const number = Number(parsed.iid ?? parsed.number) - const url = - typeof parsed.web_url === 'string' - ? parsed.web_url.trim() - : typeof parsed.webUrl === 'string' - ? parsed.webUrl.trim() - : typeof parsed.url === 'string' - ? parsed.url.trim() - : '' - if (Number.isInteger(number) && number > 0 && url) { - return { number, url } - } - } catch { - // Fall through to URL parsing for glab's normal text output. - } - const urlMatch = trimmed.match(/https?:\/\/[^\s]+\/-\/merge_requests\/(\d+)/) - if (!urlMatch) { - return null - } - return { number: Number(urlMatch[1]), url: urlMatch[0] } -} - -async function findOpenMRByHeadBase(args: { - repoPath: string - projectRef: ProjectRef - head: string - base: string - connectionId?: string | null -}): Promise<{ number: number; url: string } | null> { - const { stdout } = await glabExecFileAsync( - [ - 'mr', - 'list', - '-R', - args.projectRef.path, - '--source-branch', - args.head, - '--target-branch', - args.base, - '--per-page', - '2', - '--output', - 'json', - ...glabHostnameArgs(args.projectRef, args.connectionId) - ], - glabRepoExecOptions(args.repoPath, args.connectionId) - ) - const list = JSON.parse(stdout) as { - iid?: number - number?: number - web_url?: string - webUrl?: string - url?: string - }[] - if (list.length !== 1) { - return null - } - return parseMergeRequestPayload(JSON.stringify(list[0])) +function hostedReviewExecutionOptionArgs( + options: HostedReviewExecutionOptions +): [] | [HostedReviewExecutionOptions] { + return hasHostedReviewLocalGitOptions(options) ? [options] : [] } async function readMergeRequestTemplate( @@ -186,7 +124,8 @@ async function readMergeRequestTemplate( export async function createGitLabMergeRequest( repoPath: string, input: CreateHostedReviewInput, - connectionId?: string | null + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} ): Promise { if (input.provider !== 'gitlab') { return { @@ -196,7 +135,11 @@ export async function createGitLabMergeRequest( } } - const projectRef = await getProjectSlug(repoPath, connectionId) + const projectRef = await getProjectSlug( + repoPath, + connectionId, + ...hostedReviewExecutionOptionArgs(options) + ) if (!projectRef) { return { ok: false, @@ -252,6 +195,7 @@ export async function createGitLabMergeRequest( try { const { stdout } = await glabExecFileAsync(createArgs, { ...glabRepoExecOptions(repoPath, connectionId), + ...(connectionId ? {} : getHostedReviewLocalGitOptions(options)), timeout: 60_000, idempotent: false }) @@ -260,9 +204,14 @@ export async function createGitLabMergeRequest( return { ok: true, ...created } } const found = head - ? await findOpenMRByHeadBase({ repoPath, projectRef, head, base, connectionId }).catch( - () => null - ) + ? await findOpenMRByHeadBase({ + repoPath, + projectRef, + head, + base, + connectionId, + options + }).catch(() => null) : null if (found) { return { ok: true, ...found } @@ -284,7 +233,8 @@ export async function createGitLabMergeRequest( projectRef, head, base, - connectionId + connectionId, + options }).catch(() => null) if (existing) { return { diff --git a/src/main/gitlab/work-item-details.test.ts b/src/main/gitlab/work-item-details.test.ts index 5c5f40a5d5f..7af04c89b76 100644 --- a/src/main/gitlab/work-item-details.test.ts +++ b/src/main/gitlab/work-item-details.test.ts @@ -4,12 +4,20 @@ const { glabExecFileAsyncMock, getGlabKnownHostsMock, resolveIssueSourceMock, + glabRepoExecOptionsMock, acquireMock, releaseMock } = vi.hoisted(() => ({ glabExecFileAsyncMock: vi.fn(), getGlabKnownHostsMock: vi.fn(), resolveIssueSourceMock: vi.fn(), + glabRepoExecOptionsMock: vi.fn( + ( + repoPath: string, + connectionId?: string | null, + localGitOptions: { wslDistro?: string } = {} + ) => (connectionId ? {} : { cwd: repoPath, ...localGitOptions }) + ), acquireMock: vi.fn(), releaseMock: vi.fn() })) @@ -21,9 +29,7 @@ vi.mock('./gl-utils', () => ({ resolveIssueSource: resolveIssueSourceMock, glabExecFileAsync: glabExecFileAsyncMock, glabHostnameArgs: vi.fn(() => []), - glabRepoExecOptions: vi.fn((repoPath: string, connectionId?: string | null) => - connectionId ? {} : { cwd: repoPath } - ) + glabRepoExecOptions: glabRepoExecOptionsMock })) import { getWorkItemDetails } from './work-item-details' @@ -33,6 +39,7 @@ describe('getWorkItemDetails', () => { glabExecFileAsyncMock.mockReset() getGlabKnownHostsMock.mockReset() resolveIssueSourceMock.mockReset() + glabRepoExecOptionsMock.mockClear() acquireMock.mockReset() releaseMock.mockReset() acquireMock.mockResolvedValue(undefined) @@ -134,4 +141,65 @@ describe('getWorkItemDetails', () => { ]) expect(glabExecFileAsyncMock.mock.calls.flatMap(([args]) => args)).not.toContain('--paginate') }) + + it('routes local WSL MR detail fetches through project resolution and glab options', async () => { + const localGitOptions = { wslDistro: 'Ubuntu' } + glabExecFileAsyncMock.mockImplementation(async (args: string[]) => { + const endpoint = args.at(-1) + if (endpoint === 'projects/g%2Fp/merge_requests/12') { + return { + stdout: JSON.stringify({ + id: 120, + iid: 12, + title: 'WSL detail', + state: 'opened', + web_url: 'https://gitlab.com/g/p/-/merge_requests/12', + updated_at: '2026-06-16T00:00:00Z', + description: 'MR body', + sha: 'head-sha', + head_pipeline: null, + reviewers: [] + }) + } + } + if (endpoint === 'projects/g%2Fp/merge_requests/12/discussions?per_page=100') { + return { stdout: '[]' } + } + if (endpoint === 'projects/g%2Fp/merge_requests/12/reviewers') { + return { stdout: '[]' } + } + if (endpoint === 'projects/g%2Fp/merge_requests/12/approvals') { + return { stdout: JSON.stringify({ approvals_required: 0, approvals_left: 0 }) } + } + if (endpoint === 'projects/g%2Fp/merge_requests/12/approval_state') { + return { stdout: JSON.stringify({ rules: [] }) } + } + if (endpoint === 'projects/g%2Fp/merge_requests/12/diffs?per_page=100') { + return { stdout: '[]' } + } + throw new Error(`unexpected glab call: ${args.join(' ')}`) + }) + + const details = await getWorkItemDetails( + '/repo', + 12, + 'mr', + undefined, + null, + undefined, + localGitOptions + ) + + expect(details?.item.number).toBe(12) + expect(resolveIssueSourceMock).toHaveBeenCalledWith( + '/repo', + undefined, + ['gitlab.com'], + null, + localGitOptions + ) + expect(glabExecFileAsyncMock.mock.calls.every((call) => call[1]?.wslDistro === 'Ubuntu')).toBe( + true + ) + }) }) diff --git a/src/main/gitlab/work-item-details.ts b/src/main/gitlab/work-item-details.ts index 33cbff6eebc..33132748b1c 100644 --- a/src/main/gitlab/work-item-details.ts +++ b/src/main/gitlab/work-item-details.ts @@ -21,6 +21,7 @@ import { glabExecFileAsync, release, resolveIssueSource, + type LocalGitExecOptions, type ProjectRef } from './gl-utils' import type { IssueSourcePreference } from '../../shared/types' @@ -86,7 +87,8 @@ async function fetchDiscussions( projectRef: ProjectRef, type: 'issue' | 'mr', iid: number, - connectionId?: string | null + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} ): Promise { const resource = type === 'mr' ? 'merge_requests' : 'issues' const { stdout } = await glabExecFileAsync( @@ -97,7 +99,7 @@ async function fetchDiscussions( // Walking every historic discussion can retain and render huge note sets. `projects/${encodedProject(projectRef.path)}/${resource}/${iid}/discussions?per_page=100` ], - glabRepoExecOptions(repoPath, connectionId) + glabRepoExecOptions(repoPath, connectionId, localGitOptions) ) return JSON.parse(stdout) as GitLabRawDiscussion[] } @@ -150,7 +152,8 @@ async function fetchPipelineJobs( repoPath: string, projectRef: ProjectRef, pipelineId: number, - connectionId?: string | null + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} ): Promise { const { stdout } = await glabExecFileAsync( [ @@ -160,7 +163,7 @@ async function fetchPipelineJobs( // large pipelines; the first 100 jobs match the visible summary budget. `projects/${encodedProject(projectRef.path)}/pipelines/${pipelineId}/jobs?per_page=100` ], - glabRepoExecOptions(repoPath, connectionId) + glabRepoExecOptions(repoPath, connectionId, localGitOptions) ) const data = JSON.parse(stdout) as GitLabRawJob[] return data.map((job) => mapPipelineJob(job, pipelineId)) @@ -216,7 +219,8 @@ async function fetchMRFiles( repoPath: string, projectRef: ProjectRef, iid: number, - connectionId?: string | null + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} ): Promise { const { stdout } = await glabExecFileAsync( [ @@ -226,7 +230,7 @@ async function fetchMRFiles( // the paginated diffs endpoint; cap the file snapshot at one visible page. `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}/diffs?per_page=100` ], - glabRepoExecOptions(repoPath, connectionId) + glabRepoExecOptions(repoPath, connectionId, localGitOptions) ) const data = JSON.parse(stdout) as Parameters[0][] return data.map(mapMRFile).filter((file) => file.path) @@ -251,7 +255,8 @@ async function fetchMRReviewers( repoPath: string, projectRef: ProjectRef, iid: number, - connectionId?: string | null + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} ): Promise { const { stdout } = await glabExecFileAsync( [ @@ -259,7 +264,7 @@ async function fetchMRReviewers( ...glabHostnameArgs(projectRef, connectionId), `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}/reviewers` ], - glabRepoExecOptions(repoPath, connectionId) + glabRepoExecOptions(repoPath, connectionId, localGitOptions) ) const data = JSON.parse(stdout) as { user?: GitLabRawUser | null }[] return data @@ -271,7 +276,8 @@ async function fetchMRApprovalState( repoPath: string, projectRef: ProjectRef, iid: number, - connectionId?: string | null + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} ): Promise { const [approvalsRes, stateRes] = await Promise.allSettled([ glabExecFileAsync( @@ -280,7 +286,7 @@ async function fetchMRApprovalState( ...glabHostnameArgs(projectRef, connectionId), `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}/approvals` ], - glabRepoExecOptions(repoPath, connectionId) + glabRepoExecOptions(repoPath, connectionId, localGitOptions) ), glabExecFileAsync( [ @@ -288,7 +294,7 @@ async function fetchMRApprovalState( ...glabHostnameArgs(projectRef, connectionId), `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}/approval_state` ], - glabRepoExecOptions(repoPath, connectionId) + glabRepoExecOptions(repoPath, connectionId, localGitOptions) ) ]) if (approvalsRes.status === 'rejected' && stateRes.status === 'rejected') { @@ -343,23 +349,32 @@ export async function getWorkItemDetails( type: 'issue' | 'mr', preference?: IssueSourcePreference, connectionId?: string | null, - projectRefOverride?: ProjectRef | null + projectRefOverride?: ProjectRef | null, + localGitOptions: LocalGitExecOptions = {} ): Promise { // Why: detail fetches must use the same project source as the list row // that opened them, otherwise forked repos can show a row from one remote // and a detail sheet from another. const projectRef = projectRefOverride ?? - (await resolveIssueSource(repoPath, preference, await getGlabKnownHosts(), connectionId)).source + ( + await resolveIssueSource( + repoPath, + preference, + await getGlabKnownHosts(), + connectionId, + localGitOptions + ) + ).source if (!projectRef) { return null } await acquire() try { if (type === 'issue') { - return await fetchIssueDetails(repoPath, projectRef, iid, connectionId) + return await fetchIssueDetails(repoPath, projectRef, iid, connectionId, localGitOptions) } - return await fetchMRDetails(repoPath, projectRef, iid, connectionId) + return await fetchMRDetails(repoPath, projectRef, iid, connectionId, localGitOptions) } catch { return null } finally { @@ -371,7 +386,8 @@ async function fetchIssueDetails( repoPath: string, projectRef: ProjectRef, iid: number, - connectionId?: string | null + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} ): Promise { // Why: fan out the two reads. Issues don't have a pipeline so this // pair covers everything the dialog renders. @@ -382,9 +398,9 @@ async function fetchIssueDetails( ...glabHostnameArgs(projectRef, connectionId), `projects/${encodedProject(projectRef.path)}/issues/${iid}` ], - glabRepoExecOptions(repoPath, connectionId) + glabRepoExecOptions(repoPath, connectionId, localGitOptions) ), - fetchDiscussions(repoPath, projectRef, 'issue', iid, connectionId) + fetchDiscussions(repoPath, projectRef, 'issue', iid, connectionId, localGitOptions) ]) const issueRaw = JSON.parse(issueRes.stdout) as GitLabRawIssue const item: Omit = (() => { @@ -409,7 +425,8 @@ async function fetchMRDetails( repoPath: string, projectRef: ProjectRef, iid: number, - connectionId?: string | null + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} ): Promise { // Why: MR detail + discussions in parallel. The pipeline jobs fetch // depends on `head_pipeline.id` from the MR payload, so it has to @@ -421,9 +438,9 @@ async function fetchMRDetails( ...glabHostnameArgs(projectRef, connectionId), `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}` ], - glabRepoExecOptions(repoPath, connectionId) + glabRepoExecOptions(repoPath, connectionId, localGitOptions) ), - fetchDiscussions(repoPath, projectRef, 'mr', iid, connectionId) + fetchDiscussions(repoPath, projectRef, 'mr', iid, connectionId, localGitOptions) ]) const mrRaw = JSON.parse(mrRes.stdout) as GitLabRawMR const item: Omit = (() => { @@ -434,14 +451,22 @@ async function fetchMRDetails( const pipelineId = mrRaw.head_pipeline?.id const pipelineJobs = typeof pipelineId === 'number' - ? await fetchPipelineJobs(repoPath, projectRef, pipelineId, connectionId).catch(() => []) + ? await fetchPipelineJobs( + repoPath, + projectRef, + pipelineId, + connectionId, + localGitOptions + ).catch(() => []) : undefined const [reviewers, approvalState, files] = await Promise.all([ - fetchMRReviewers(repoPath, projectRef, iid, connectionId).catch(() => + fetchMRReviewers(repoPath, projectRef, iid, connectionId, localGitOptions).catch(() => (mrRaw.reviewers ?? []).map(mapGitLabUser).filter((u): u is GitLabAssignableUser => !!u) ), - fetchMRApprovalState(repoPath, projectRef, iid, connectionId).catch(() => undefined), - fetchMRFiles(repoPath, projectRef, iid, connectionId).catch(() => []) + fetchMRApprovalState(repoPath, projectRef, iid, connectionId, localGitOptions).catch( + () => undefined + ), + fetchMRFiles(repoPath, projectRef, iid, connectionId, localGitOptions).catch(() => []) ]) return { item, diff --git a/src/main/grok/hook-service.test.ts b/src/main/grok/hook-service.test.ts index b64c70d7be1..fd5467b66b3 100644 --- a/src/main/grok/hook-service.test.ts +++ b/src/main/grok/hook-service.test.ts @@ -17,6 +17,8 @@ vi.mock('os', async () => { import { GrokHookService } from './hook-service' +const GROK_SCRIPT_FILE_NAME = process.platform === 'win32' ? 'grok-hook.cmd' : 'grok-hook.sh' + describe('GrokHookService', () => { let homeDir: string @@ -60,9 +62,16 @@ describe('GrokHookService', () => { expect(config.hooks.PreToolUse[0].hooks[0].command).toContain('grok-hook') expect(config.hooks.PreToolUse[0].hooks[0].command).toContain(join(homeDir, '.orca')) - const script = readFileSync(join(homeDir, '.orca', 'agent-hooks', 'grok-hook.sh'), 'utf8') + const script = readFileSync( + join(homeDir, '.orca', 'agent-hooks', GROK_SCRIPT_FILE_NAME), + 'utf8' + ) expect(script).toContain('/hook/grok') - expect(script).toContain('payload=$(cat)') + if (process.platform === 'win32') { + expect(script).toContain('powershell -NoProfile') + } else { + expect(script).toContain('payload=$(cat)') + } }) it('preserves user-authored hook entries in the Orca Grok config file', () => { @@ -90,6 +99,6 @@ describe('GrokHookService', () => { definition.hooks.map((hook) => hook.command) ) expect(commands).toContain('/usr/local/bin/user-hook') - expect(commands.some((command) => command.includes('grok-hook.sh'))).toBe(true) + expect(commands.some((command) => command.includes(GROK_SCRIPT_FILE_NAME))).toBe(true) }) }) diff --git a/src/main/hooks.test.ts b/src/main/hooks.test.ts index 5e9269075a8..dcaa1fc98e9 100644 --- a/src/main/hooks.test.ts +++ b/src/main/hooks.test.ts @@ -1,6 +1,7 @@ /* eslint-disable max-lines -- Why: hook parsing, shell selection, and execution-path regressions are tightly coupled, so these cases stay in one file to preserve the behavior matrix across platforms. */ import type { Repo } from '../shared/types' +import { join } from 'path' import { describe, expect, it, vi } from 'vitest' import { getDefaultTabsLaunch, parseOrcaYaml } from './hooks' @@ -14,9 +15,10 @@ vi.mock('fs', () => ({ chmodSync: vi.fn() })) -const { execMock, execFileMock } = vi.hoisted(() => ({ +const { execMock, execFileMock, gitExecFileSyncMock } = vi.hoisted(() => ({ execMock: vi.fn(), - execFileMock: vi.fn() + execFileMock: vi.fn(), + gitExecFileSyncMock: vi.fn() })) vi.mock('child_process', () => ({ @@ -27,6 +29,10 @@ vi.mock('child_process', () => ({ spawn: vi.fn() })) +vi.mock('./git/runner', () => ({ + gitExecFileSync: gitExecFileSyncMock +})) + describe('parseOrcaYaml', () => { it('parses YAML with setup script only', () => { const yaml = `scripts:\n setup: |\n echo "setting up"\n npm install\n` @@ -249,45 +255,51 @@ describe('hasUnrecognizedOrcaYamlKeys', () => { describe('readIssueCommand', () => { it('prefers the local override over the shared orca.yaml command', async () => { const fs = await import('fs') + const repoPath = join('/test', 'repo') + const localIssueCommandPath = join(repoPath, '.orca', 'issue-command') + const sharedConfigPath = join(repoPath, 'orca.yaml') vi.mocked(fs.existsSync).mockImplementation( - (path) => path === '/test/repo/.orca/issue-command' || path === '/test/repo/orca.yaml' + (path) => path === localIssueCommandPath || path === sharedConfigPath ) vi.mocked(fs.readFileSync).mockImplementation((path) => { - if (path === '/test/repo/.orca/issue-command') { + if (path === localIssueCommandPath) { return 'local command\n' } - if (path === '/test/repo/orca.yaml') { + if (path === sharedConfigPath) { return 'issueCommand: |\n shared command\n' } return '' }) const { readIssueCommand } = await import('./hooks') - expect(readIssueCommand('/test/repo')).toEqual({ + expect(readIssueCommand(repoPath)).toEqual({ localContent: 'local command', sharedContent: 'shared command', effectiveContent: 'local command', - localFilePath: '/test/repo/.orca/issue-command', + localFilePath: localIssueCommandPath, source: 'local' }) }) it('falls back to the shared orca.yaml command when no local override exists', async () => { const fs = await import('fs') - vi.mocked(fs.existsSync).mockImplementation((path) => path === '/test/repo/orca.yaml') + const repoPath = join('/test', 'repo') + const localIssueCommandPath = join(repoPath, '.orca', 'issue-command') + const sharedConfigPath = join(repoPath, 'orca.yaml') + vi.mocked(fs.existsSync).mockImplementation((path) => path === sharedConfigPath) vi.mocked(fs.readFileSync).mockImplementation((path) => { - if (path === '/test/repo/orca.yaml') { + if (path === sharedConfigPath) { return 'issueCommand: |\n shared command\n' } return '' }) const { readIssueCommand } = await import('./hooks') - expect(readIssueCommand('/test/repo')).toEqual({ + expect(readIssueCommand(repoPath)).toEqual({ localContent: null, sharedContent: 'shared command', effectiveContent: 'shared command', - localFilePath: '/test/repo/.orca/issue-command', + localFilePath: localIssueCommandPath, source: 'shared' }) }) @@ -296,26 +308,30 @@ describe('readIssueCommand', () => { describe('writeIssueCommand', () => { it('writes only the local override file and keeps .orca ignored locally', async () => { const fs = await import('fs') + const repoPath = join('/test', 'repo') + const gitignorePath = join(repoPath, '.gitignore') + const localOrcaDir = join(repoPath, '.orca') + const localIssueCommandPath = join(localOrcaDir, 'issue-command') vi.mocked(fs.existsSync).mockImplementation( - (path) => path === '/test/repo/.gitignore' || path === '/test/repo/.orca' + (path) => path === gitignorePath || path === localOrcaDir ) vi.mocked(fs.readFileSync).mockImplementation((path) => { - if (path === '/test/repo/.gitignore') { + if (path === gitignorePath) { return 'node_modules/\n' } return '' }) const { writeIssueCommand } = await import('./hooks') - writeIssueCommand('/test/repo', 'local command') + writeIssueCommand(repoPath, 'local command') expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledWith( - '/test/repo/.gitignore', + gitignorePath, 'node_modules/\n.orca\n', 'utf-8' ) expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledWith( - '/test/repo/.orca/issue-command', + localIssueCommandPath, 'local command\n', 'utf-8' ) @@ -323,10 +339,12 @@ describe('writeIssueCommand', () => { it('deletes the local override when the override is cleared', async () => { const fs = await import('fs') + const repoPath = join('/test', 'repo') + const localIssueCommandPath = join(repoPath, '.orca', 'issue-command') const { writeIssueCommand } = await import('./hooks') - writeIssueCommand('/test/repo', ' ') + writeIssueCommand(repoPath, ' ') - expect(vi.mocked(fs.rmSync)).toHaveBeenCalledWith('/test/repo/.orca/issue-command', { + expect(vi.mocked(fs.rmSync)).toHaveBeenCalledWith(localIssueCommandPath, { force: true }) }) @@ -368,21 +386,25 @@ describe('getEffectiveHooks', () => { it("loads setup hooks from the target worktree's orca.yaml when a worktree path is provided", async () => { const fs = await import('fs') + const repoPath = join('/test', 'repo') + const worktreePath = join('/test', 'worktree') + const repoConfigPath = join(repoPath, 'orca.yaml') + const worktreeConfigPath = join(worktreePath, 'orca.yaml') vi.mocked(fs.existsSync).mockImplementation( - (path) => path === '/test/repo/orca.yaml' || path === '/test/worktree/orca.yaml' + (path) => path === repoConfigPath || path === worktreeConfigPath ) vi.mocked(fs.readFileSync).mockImplementation((path) => { - if (path === '/test/repo/orca.yaml') { + if (path === repoConfigPath) { return 'scripts:\n setup: |\n echo old-version\n' } - if (path === '/test/worktree/orca.yaml') { + if (path === worktreeConfigPath) { return 'scripts:\n setup: |\n echo new-version\n' } return '' }) const { getEffectiveHooks } = await import('./hooks') - const result = getEffectiveHooks(makeRepo(), '/test/worktree') + const result = getEffectiveHooks(makeRepo(), worktreePath) expect(result).toEqual({ scripts: { @@ -796,6 +818,120 @@ describe('runHook', () => { } }) + it('runs Windows-path hooks through WSL when the project runtime targets WSL', async () => { + execMock.mockReset() + execFileMock.mockReset() + execFileMock.mockImplementation((_file, _args, options, callback) => { + callback?.(null, '', '') + expect(options).toEqual( + expect.objectContaining({ + env: expect.objectContaining({ + ORCA_ROOT_PATH: '/mnt/c/Users/jinwo/git/orca', + ORCA_WORKTREE_PATH: '/mnt/c/Users/jinwo/git/orca-feature', + CONDUCTOR_ROOT_PATH: '/mnt/c/Users/jinwo/git/orca', + GHOSTX_ROOT_PATH: '/mnt/c/Users/jinwo/git/orca' + }) + }) + ) + return {} as never + }) + + const fs = await import('fs') + vi.mocked(fs.existsSync).mockReturnValue(true) + vi.mocked(fs.readFileSync).mockReturnValue('scripts:\n setup: |\n echo hello\n') + + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + + try { + const { runHook } = await import('./hooks') + const result = await runHook( + 'setup', + 'C:\\Users\\jinwo\\git\\orca-feature', + { + ...makeRepo(), + path: 'C:\\Users\\jinwo\\git\\orca' + }, + undefined, + { wslDistro: 'Ubuntu' } + ) + + expect(result).toEqual({ success: true, output: '' }) + expect(execFileMock).toHaveBeenCalledWith( + 'wsl.exe', + [ + '-d', + 'Ubuntu', + '--', + 'bash', + '-c', + "cd '/mnt/c/Users/jinwo/git/orca-feature' && echo hello" + ], + expect.any(Object), + expect.any(Function) + ) + expect(execMock).not.toHaveBeenCalled() + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + }) + + it('writes Windows-path setup runners through WSL git when the project runtime targets WSL', async () => { + gitExecFileSyncMock.mockReset() + gitExecFileSyncMock.mockReturnValue('/mnt/c/Users/jinwo/git/orca/.git/orca/setup-runner.sh\n') + + const fs = await import('fs') + const mkdirSyncMock = vi.mocked(fs.mkdirSync) + const writeFileSyncMock = vi.mocked(fs.writeFileSync) + const chmodSyncMock = vi.mocked(fs.chmodSync) + + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + + try { + const { createSetupRunnerScript } = await import('./hooks') + const result = createSetupRunnerScript( + { + ...makeRepo(), + path: 'C:\\Users\\jinwo\\git\\orca' + }, + 'C:\\Users\\jinwo\\git\\orca-feature', + 'echo hello', + { wslDistro: 'Ubuntu' } + ) + + expect(gitExecFileSyncMock).toHaveBeenCalledWith( + ['rev-parse', '--git-path', 'orca/setup-runner.sh'], + { + cwd: 'C:\\Users\\jinwo\\git\\orca-feature', + wslDistro: 'Ubuntu' + } + ) + expect(result.runnerScriptPath).toContain('setup-runner.sh') + expect(mkdirSyncMock).toHaveBeenCalled() + expect(writeFileSyncMock).toHaveBeenCalledWith( + expect.stringContaining('setup-runner.sh'), + '#!/usr/bin/env bash\nset -e\necho hello\n', + 'utf-8' + ) + expect(chmodSyncMock).toHaveBeenCalledWith(expect.stringContaining('setup-runner.sh'), 0o755) + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + }) + it('settles WSL hooks when wsl.exe never reports completion', async () => { vi.useFakeTimers() execMock.mockReset() diff --git a/src/main/hooks.ts b/src/main/hooks.ts index b63c3fc934b..54ec922a57d 100644 --- a/src/main/hooks.ts +++ b/src/main/hooks.ts @@ -18,9 +18,14 @@ import type { WorktreeDefaultTabsLaunch, WorktreeSetupLaunch } from '../shared/types' +import type { ProjectExecutionRuntimeResolution } from '../shared/project-execution-runtime' const HOOK_TIMEOUT = 120_000 // 2 minutes +export type HookRuntimeTarget = { + wslDistro?: string | null +} + function getHookShell(): string | undefined { if (process.platform === 'win32') { return process.env.ComSpec || 'cmd.exe' @@ -410,12 +415,56 @@ function getSetupEnvVars(repo: Repo, worktreePath: string): Record { @@ -458,26 +514,34 @@ export function buildPosixRunnerScript(script: string): string { export function createIssueCommandRunnerScript( repo: Repo, worktreePath: string, - command: string + command: string, + projectRuntime?: ProjectExecutionRuntimeResolution | HookRuntimeTarget ): WorktreeSetupLaunch { // Why: long issue-automation commands are user-visible shell input when // written directly to the PTY, so terminal line editors can wrap or truncate // them before execution. Writing the real command into a runner script keeps // the shell startup path short and mirrors the already-stable setup runner // flow instead of inventing a second launch mechanism. - return createWorktreeRunnerScript(repo, worktreePath, command, 'issue-command-runner') + return createWorktreeRunnerScript( + repo, + worktreePath, + command, + 'issue-command-runner', + getHookRuntimeTarget(projectRuntime) + ) } function createWorktreeRunnerScript( repo: Repo, worktreePath: string, script: string, - runnerBaseName: 'setup-runner' | 'issue-command-runner' + runnerBaseName: 'setup-runner' | 'issue-command-runner', + runtimeTarget?: HookRuntimeTarget ): WorktreeSetupLaunch { const envVars = getSetupEnvVars(repo, worktreePath) // Why: WSL worktrees run on a Linux filesystem even though process.platform // is 'win32'. Use bash scripts for WSL, .cmd for native Windows. - const wslWorktree = isWslPath(worktreePath) + const wslWorktree = isWslPath(worktreePath) || Boolean(runtimeTarget?.wslDistro) const useWindowsFormat = process.platform === 'win32' && !wslWorktree const normalizedScript = useWindowsFormat ? script.replace(/\r?\n/g, '\r\n') @@ -486,14 +550,14 @@ function createWorktreeRunnerScript( // so writing under `${worktreePath}/.git/...` fails. `git rev-parse --git-path` // resolves the actual per-worktree git storage path safely across platforms. const gitRelPath = useWindowsFormat ? `orca/${runnerBaseName}.cmd` : `orca/${runnerBaseName}.sh` - let runnerScriptPath = getGitPath(worktreePath, gitRelPath) + let runnerScriptPath = getGitPath(worktreePath, gitRelPath, runtimeTarget) // Why: for WSL worktrees, getGitPath returns a Linux path (e.g. /home/user/...) // because git runs inside WSL. Convert it to a Windows UNC path so mkdirSync // and writeFileSync (which run on Windows) can access it. if (wslWorktree) { - const wslInfo = parseWslPath(worktreePath) - if (wslInfo) { + const wslInfo = getHookWslContext(worktreePath, runtimeTarget) + if (wslInfo?.distro) { runnerScriptPath = toWindowsWslPath(runnerScriptPath.trim(), wslInfo.distro) } } @@ -528,7 +592,8 @@ export function runHook( hookName: 'setup' | 'archive', cwd: string, repo: Repo, - hooksPath?: string + hooksPath?: string, + projectRuntime?: ProjectExecutionRuntimeResolution | HookRuntimeTarget ): Promise<{ success: boolean; output: string }> { const hooks = getEffectiveHooks(repo, hooksPath) const script = hooks?.scripts[hookName] @@ -537,7 +602,8 @@ export function runHook( return Promise.resolve({ success: true, output: '' }) } - const wslInfo = parseWslPath(cwd) + const runtimeTarget = getHookRuntimeTarget(projectRuntime) + const wslInfo = getHookWslContext(cwd, runtimeTarget) if (wslInfo) { // Why: use execFile('wsl.exe', [...]) instead of exec() to bypass the @@ -589,9 +655,10 @@ export function runHook( }, HOOK_TIMEOUT) try { + const distroArgs = wslInfo.distro ? ['-d', wslInfo.distro] : [] child = execFile( 'wsl.exe', - ['-d', wslInfo.distro, '--', 'bash', '-c', bashCmd], + [...distroArgs, '--', 'bash', '-c', bashCmd], { timeout: HOOK_TIMEOUT, encoding: 'utf-8', diff --git a/src/main/index.ts b/src/main/index.ts index e36d093429b..892b74a6f26 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -704,7 +704,7 @@ function openMainWindow(): BrowserWindow { automations, { prepareForCodexLaunch: prepareCodexRuntimeHomeForLaunch, - prepareForClaudeLaunch: () => claudeRuntimeAuth!.prepareForClaudeLaunch() + prepareForClaudeLaunch: (target) => claudeRuntimeAuth!.prepareForClaudeLaunch(target) }, agentAwakeService ?? undefined, crashReports ?? undefined, @@ -1437,7 +1437,7 @@ app.whenReady().then(async () => { // even for the system-default path, so every Orca-launched Codex process // must resolve CODEX_HOME through the runtime-home service. prepareForCodexLaunch: prepareCodexRuntimeHomeForLaunch, - prepareForClaudeLaunch: () => claudeRuntimeAuth!.prepareForClaudeLaunch() + prepareForClaudeLaunch: (target) => claudeRuntimeAuth!.prepareForClaudeLaunch(target) }) starNag = new StarNagService(store, stats) starNag.start() diff --git a/src/main/ipc/filesystem-auth.test.ts b/src/main/ipc/filesystem-auth.test.ts index 7d0271249f3..979d6b5611f 100644 --- a/src/main/ipc/filesystem-auth.test.ts +++ b/src/main/ipc/filesystem-auth.test.ts @@ -291,7 +291,7 @@ describe('filesystem-auth path containment', () => { it('allows git-relative files under dotdot-prefixed child directories', () => { expect(validateGitRelativeFilePath(resolve('/workspace/repo'), '..fixtures/file.ts')).toBe( - '..fixtures/file.ts' + join('..fixtures', 'file.ts') ) }) diff --git a/src/main/ipc/filesystem-list-files-git-fallback-real.test.ts b/src/main/ipc/filesystem-list-files-git-fallback-real.test.ts index 327614faf0a..17ae0a86621 100644 --- a/src/main/ipc/filesystem-list-files-git-fallback-real.test.ts +++ b/src/main/ipc/filesystem-list-files-git-fallback-real.test.ts @@ -45,17 +45,15 @@ describe('filesystem-list-files real git fallback', () => { vi.clearAllMocks() }) - it('returns real paths for filenames Git would C-quote in newline output', async () => { + it('returns real paths for UTF-8 filenames from the git fallback', async () => { checkRgAvailableMock.mockResolvedValue(false) tempDir = await mkdtemp(join(tmpdir(), 'orca-quick-open-git-fallback-')) const repoPath = join(tempDir, 'repo') await execFile('git', ['init', '-q', repoPath]) - const tabbedPath = join(repoPath, 'tab\tfile.txt') - await writeFile(tabbedPath, 'content') + const utf8FileName = '日本語-file.txt' + await writeFile(join(repoPath, utf8FileName), 'content') await execFile('git', ['add', '.'], { cwd: repoPath }) - await expect(listQuickOpenFiles(repoPath, makeStore(repoPath))).resolves.toEqual([ - 'tab\tfile.txt' - ]) + await expect(listQuickOpenFiles(repoPath, makeStore(repoPath))).resolves.toEqual([utf8FileName]) }) }) diff --git a/src/main/ipc/filesystem-list-files.test.ts b/src/main/ipc/filesystem-list-files.test.ts index a09739b143d..499f91c04be 100644 --- a/src/main/ipc/filesystem-list-files.test.ts +++ b/src/main/ipc/filesystem-list-files.test.ts @@ -1,10 +1,16 @@ /* eslint-disable max-lines -- Why: one Quick Open file-list suite covers both rg and git fallback process lifecycles. */ import { describe, expect, it, vi, beforeEach } from 'vitest' -const { spawnMock, resolveAuthorizedPathMock, checkRgAvailableMock } = vi.hoisted(() => ({ +const { + spawnMock, + resolveAuthorizedPathMock, + checkRgAvailableMock, + getLocalGitOptionsForRegisteredWorktreeMock +} = vi.hoisted(() => ({ spawnMock: vi.fn(), resolveAuthorizedPathMock: vi.fn(), - checkRgAvailableMock: vi.fn() + checkRgAvailableMock: vi.fn(), + getLocalGitOptionsForRegisteredWorktreeMock: vi.fn() })) vi.mock('child_process', () => ({ @@ -23,6 +29,10 @@ vi.mock('./rg-availability', () => ({ checkRgAvailable: checkRgAvailableMock })) +vi.mock('./local-worktree-runtime-options', () => ({ + getLocalGitOptionsForRegisteredWorktree: getLocalGitOptionsForRegisteredWorktreeMock +})) + import { listQuickOpenFiles } from './filesystem-list-files' import { EventEmitter } from 'events' import type { Store } from '../persistence' @@ -53,6 +63,7 @@ describe('filesystem-list-files', () => { vi.clearAllMocks() resolveAuthorizedPathMock.mockImplementation(async (path) => path) checkRgAvailableMock.mockResolvedValue(true) + getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({}) }) it('merges normal files and ignored files and filters correctly', async () => { @@ -98,6 +109,60 @@ describe('filesystem-list-files', () => { ]) }) + it('checks rg availability inside the registered WSL runtime for Windows-path worktrees', async () => { + const p1 = createMockProcess() + const p2 = createMockProcess() + getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' }) + + spawnMock.mockImplementation((_cmd, args: string[]) => { + if (isIgnoredRgPass(args)) { + return p2 + } + return p1 + }) + + const storeMock = {} as unknown as Store + const promise = listQuickOpenFiles('C:\\repo', storeMock) + + setTimeout(() => { + ;(p1.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n') + p1.emit('close', 0, null) + p2.emit('close', 0, null) + }, 10) + + await expect(promise).resolves.toEqual(['src/index.ts']) + expect(getLocalGitOptionsForRegisteredWorktreeMock).toHaveBeenCalledWith( + storeMock, + 'C:\\repo', + 'C:\\repo' + ) + expect(checkRgAvailableMock).toHaveBeenCalledWith('C:\\repo', 'Ubuntu') + }) + + it('normalizes absolute WSL rg output for Windows-path worktrees', async () => { + const p1 = createMockProcess() + const p2 = createMockProcess() + getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' }) + + spawnMock.mockImplementation((_cmd, args: string[]) => { + if (isIgnoredRgPass(args)) { + return p2 + } + return p1 + }) + + const storeMock = {} as unknown as Store + const promise = listQuickOpenFiles('C:\\repo', storeMock) + + setTimeout(() => { + ;(p1.stdout as unknown as EventEmitter).emit('data', '/mnt/c/repo/src/index.ts\n') + p1.emit('close', 0, null) + p2.emit('close', 0, null) + }, 10) + + await expect(promise).resolves.toEqual(['src/index.ts']) + }) + it('rejects rg failures instead of resolving a false-empty list', async () => { const p1 = createMockProcess() const p2 = createMockProcess() diff --git a/src/main/ipc/filesystem-list-files.ts b/src/main/ipc/filesystem-list-files.ts index 90ff30895dc..2dd3cd9ce44 100644 --- a/src/main/ipc/filesystem-list-files.ts +++ b/src/main/ipc/filesystem-list-files.ts @@ -5,11 +5,13 @@ import { resolveAuthorizedPath } from './filesystem-auth' import { checkRgAvailable } from './rg-availability' import { gitSpawn, wslAwareSpawn } from '../git/runner' import { parseWslPath, toWindowsWslPath } from '../wsl' +import { getLocalGitOptionsForRegisteredWorktree } from './local-worktree-runtime-options' import { buildExcludePathPrefixes, buildGitLsFilesArgsForQuickOpen, buildRgArgsForQuickOpen, normalizeQuickOpenRgLine, + type RgOutputMode, shouldExcludeQuickOpenRelPath, shouldIncludeQuickOpenPath } from '../../shared/quick-open-filter' @@ -20,6 +22,11 @@ export async function listQuickOpenFiles( excludePaths?: string[] ): Promise { const authorizedRootPath = await resolveAuthorizedPath(rootPath, store) + const localGitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + rootPath, + authorizedRootPath + ) // Why: when the main worktree sits at the repo root, linked worktrees are // nested subdirectories. Without excluding them, rg/git lists files from @@ -31,17 +38,16 @@ export async function listQuickOpenFiles( // spawn('rg') emits 'close' before 'error' on some platforms, causing // the handler to resolve with empty results before the git fallback // can run. - const rgAvailable = await checkRgAvailable(authorizedRootPath) + const rgAvailable = await checkRgAvailable(authorizedRootPath, localGitOptions.wslDistro) if (!rgAvailable) { - return listFilesWithGit(authorizedRootPath, excludePathPrefixes) + return listFilesWithGit(authorizedRootPath, excludePathPrefixes, localGitOptions) } const files = new Set() const children: ChildProcess[] = [] - // Why: when rg runs inside WSL, output paths are Linux-native - // (e.g. /home/user/repo/src/file.ts). Translate them back to Windows - // UNC paths up-front before the shared line normalizer runs. - const wslInfo = parseWslPath(authorizedRootPath) + // Why: WSL-routed rg can emit Linux-native absolute paths. UNC repos carry + // their distro in the path; Windows-path repos carry it in project runtime. + const wslDistroForOutput = parseWslPath(authorizedRootPath)?.distro ?? localGitOptions.wslDistro const { primary, ignoredPass } = buildRgArgsForQuickOpen({ // Why: rg evaluates root-relative exclude globs against cwd only when the @@ -62,8 +68,13 @@ export async function listQuickOpenFiles( const processLine = (rawLine: string): void => { const translated = - wslInfo && rawLine.startsWith('/') ? toWindowsWslPath(rawLine, wslInfo.distro) : rawLine - const relPath = normalizeQuickOpenRgLine(translated, { kind: 'cwd-relative' }) + wslDistroForOutput && rawLine.startsWith('/') + ? toWindowsWslPath(rawLine, wslDistroForOutput) + : rawLine + const relPath = normalizeQuickOpenRgLine( + translated, + getQuickOpenRgOutputMode(rawLine, translated, authorizedRootPath) + ) if (relPath === null) { return } @@ -79,6 +90,7 @@ export async function listQuickOpenFiles( const child = wslAwareSpawn('rg', args, { cwd: authorizedRootPath, + ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), stdio: ['ignore', 'pipe', 'pipe'] }) children.push(child) @@ -179,6 +191,22 @@ export async function listQuickOpenFiles( return Array.from(files) } +function getQuickOpenRgOutputMode( + rawLine: string, + translatedLine: string, + rootPath: string +): RgOutputMode { + if ( + translatedLine !== rawLine || + rawLine.startsWith('/') || + /^[A-Za-z]:[\\/]/.test(rawLine) || + rawLine.startsWith('\\\\') + ) { + return { kind: 'absolute', rootPath } + } + return { kind: 'cwd-relative' } +} + /** * Fallback file lister using git ls-files. Used when rg is not available. * @@ -188,7 +216,8 @@ export async function listQuickOpenFiles( */ function listFilesWithGit( rootPath: string, - excludePathPrefixes: readonly string[] + excludePathPrefixes: readonly string[], + localGitOptions: { wslDistro?: string } ): Promise { const files = new Set() const { primary, ignoredPass } = buildGitLsFilesArgsForQuickOpen(excludePathPrefixes) @@ -217,6 +246,7 @@ function listFilesWithGit( // rootPath and use the output directly — no prefix stripping needed. const child = gitSpawn(['ls-files', ...args], { cwd: rootPath, + ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), stdio: ['ignore', 'pipe', 'pipe'] }) let timer: ReturnType diff --git a/src/main/ipc/filesystem-search-git.ts b/src/main/ipc/filesystem-search-git.ts index 41853c0f4d5..065dbd11dad 100644 --- a/src/main/ipc/filesystem-search-git.ts +++ b/src/main/ipc/filesystem-search-git.ts @@ -19,7 +19,8 @@ import { gitSpawn } from '../git/runner' export function searchWithGitGrep( rootPath: string, args: SearchOptions, - maxResults: number + maxResults: number, + localGitOptions: { wslDistro?: string } = {} ): Promise { return new Promise((resolve) => { const gitArgs = buildGitGrepArgs(args.query, args) @@ -30,6 +31,7 @@ export function searchWithGitGrep( const child = gitSpawn(gitArgs, { cwd: rootPath, + ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), stdio: ['ignore', 'pipe', 'pipe'] }) let killTimeout: ReturnType diff --git a/src/main/ipc/filesystem-search-rg-timeout.test.ts b/src/main/ipc/filesystem-search-rg-timeout.test.ts index 2619426824a..321004e0620 100644 --- a/src/main/ipc/filesystem-search-rg-timeout.test.ts +++ b/src/main/ipc/filesystem-search-rg-timeout.test.ts @@ -2,13 +2,21 @@ import { EventEmitter } from 'events' import type { ChildProcess } from 'child_process' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { handleMock, resolveAuthorizedPathMock, checkRgAvailableMock, wslAwareSpawnMock } = - vi.hoisted(() => ({ - handleMock: vi.fn(), - resolveAuthorizedPathMock: vi.fn(), - checkRgAvailableMock: vi.fn(), - wslAwareSpawnMock: vi.fn() - })) +const { + handleMock, + resolveAuthorizedPathMock, + checkRgAvailableMock, + getLocalGitOptionsForRegisteredWorktreeMock, + wslAwareSpawnMock, + toWindowsWslPathMock +} = vi.hoisted(() => ({ + handleMock: vi.fn(), + resolveAuthorizedPathMock: vi.fn(), + checkRgAvailableMock: vi.fn(), + getLocalGitOptionsForRegisteredWorktreeMock: vi.fn(), + wslAwareSpawnMock: vi.fn(), + toWindowsWslPathMock: vi.fn((value: string) => value) +})) const handlers = new Map Promise | unknown>() @@ -28,7 +36,7 @@ vi.mock('../git/runner', () => ({ vi.mock('../wsl', () => ({ parseWslPath: vi.fn(() => null), - toWindowsWslPath: vi.fn((value: string) => value) + toWindowsWslPath: toWindowsWslPathMock })) vi.mock('./filesystem-auth', () => ({ @@ -51,6 +59,10 @@ vi.mock('./filesystem-search-git', () => ({ searchWithGitGrep: vi.fn() })) +vi.mock('./local-worktree-runtime-options', () => ({ + getLocalGitOptionsForRegisteredWorktree: getLocalGitOptionsForRegisteredWorktreeMock +})) + vi.mock('./markdown-documents', () => ({ listMarkdownDocuments: vi.fn(), markdownDocumentsFromRelativePaths: vi.fn() @@ -84,6 +96,7 @@ describe('filesystem rg search timeout', () => { }) resolveAuthorizedPathMock.mockImplementation(async (value: string) => value) checkRgAvailableMock.mockResolvedValue(true) + getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({}) }) it('settles and detaches when rg ignores the timeout kill', async () => { @@ -116,4 +129,79 @@ describe('filesystem rg search timeout', () => { vi.useRealTimers() } }) + + it('routes rg through the registered WSL project runtime for Windows-path worktrees', async () => { + const child = createMockProcess() + wslAwareSpawnMock.mockReturnValue(child) + getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' }) + registerFilesystemHandlers({} as never) + + const promise = handlers.get('fs:search')!( + { sender: { id: 7 } }, + { rootPath: 'C:\\repo', query: 'ok' } + ) as Promise + + setTimeout(() => { + child.emit('close') + }, 10) + + await promise + + expect(checkRgAvailableMock).toHaveBeenCalledWith('C:\\repo', 'Ubuntu') + expect(wslAwareSpawnMock).toHaveBeenCalledWith( + 'rg', + expect.any(Array), + expect.objectContaining({ + cwd: 'C:\\repo', + wslDistro: 'Ubuntu' + }) + ) + }) + + it('translates WSL rg output for Windows-path project search results', async () => { + const child = createMockProcess() + wslAwareSpawnMock.mockReturnValue(child) + getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' }) + toWindowsWslPathMock.mockImplementation((value: string) => + value.replace('/mnt/c/repo', 'C:\\repo').replace(/\//g, '\\') + ) + registerFilesystemHandlers({} as never) + + const promise = handlers.get('fs:search')!( + { sender: { id: 7 } }, + { rootPath: 'C:\\repo', query: 'hello' } + ) as Promise<{ + files: { filePath: string; relativePath: string; matchCount: number }[] + }> + + setTimeout(() => { + if (!child.stdout) { + throw new Error('mock child stdout missing') + } + child.stdout.emit( + 'data', + `${JSON.stringify({ + type: 'match', + data: { + path: { text: '/mnt/c/repo/src/index.ts' }, + lines: { text: 'hello world\n' }, + line_number: 3, + submatches: [{ start: 0, end: 5 }] + } + })}\n` + ) + child.emit('close') + }, 10) + + const result = await promise + + expect(result.files).toEqual([ + expect.objectContaining({ + filePath: 'C:\\repo\\src\\index.ts', + relativePath: 'src/index.ts', + matchCount: 1 + }) + ]) + expect(toWindowsWslPathMock).toHaveBeenCalledWith('/mnt/c/repo/src/index.ts', 'Ubuntu') + }) }) diff --git a/src/main/ipc/filesystem-watcher-large-batch.test.ts b/src/main/ipc/filesystem-watcher-large-batch.test.ts index 9e2fd91def6..e36066463fc 100644 --- a/src/main/ipc/filesystem-watcher-large-batch.test.ts +++ b/src/main/ipc/filesystem-watcher-large-batch.test.ts @@ -1,3 +1,4 @@ +import { join, resolve } from 'path' import { beforeEach, describe, expect, it, vi } from 'vitest' const { handleMock } = vi.hoisted(() => ({ @@ -54,6 +55,7 @@ describe('local filesystem watcher large batches', () => { it('accepts a large local watcher event batch without overflowing V8 arguments', async () => { vi.useFakeTimers() vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never) + const worktreePath = resolve('/tmp/repo') let watcherCallback: ((err: Error | null, events: WatcherEvent[]) => void) | undefined vi.mocked(subscribeParcelWatcher).mockImplementation(async (_root, callback) => { watcherCallback = callback as typeof watcherCallback @@ -62,12 +64,12 @@ describe('local filesystem watcher large batches', () => { await handlers['fs:watchWorktree']( { sender: { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } }, - { worktreePath: '/tmp/repo' } + { worktreePath } ) const events = Array.from( { length: 200_000 }, - (_, index): WatcherEvent => ({ type: 'delete', path: `/tmp/repo/file-${index}` }) + (_, index): WatcherEvent => ({ type: 'delete', path: join(worktreePath, `file-${index}`) }) ) expect(() => watcherCallback?.(null, events)).not.toThrow() @@ -83,14 +85,18 @@ describe('local filesystem watcher large batches', () => { watcherCallback = callback as typeof watcherCallback return { unsubscribe: vi.fn() } as never }) + const worktreePath = resolve('/tmp/repo') const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } - await handlers['fs:watchWorktree']({ sender }, { worktreePath: '/tmp/repo' }) + await handlers['fs:watchWorktree']({ sender }, { worktreePath }) watcherCallback?.( null, Array.from( { length: 5_001 }, - (_, index): WatcherEvent => ({ type: 'update', path: `/tmp/repo/file-${index}.txt` }) + (_, index): WatcherEvent => ({ + type: 'update', + path: join(worktreePath, `file-${index}.txt`) + }) ) ) @@ -98,8 +104,8 @@ describe('local filesystem watcher large batches', () => { expect(stat).toHaveBeenCalledTimes(1) expect(sender.send).toHaveBeenCalledWith('fs:changed', { - worktreePath: '/tmp/repo', - events: [{ kind: 'overflow', absolutePath: '/tmp/repo' }] + worktreePath, + events: [{ kind: 'overflow', absolutePath: worktreePath }] }) await closeAllWatchers() vi.useRealTimers() diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index 8321511b15c..d2e893f57be 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -196,6 +196,18 @@ function dirEntry({ name, directory, file, symlink }: MockDirEntry): { } } +async function withPlatform(platform: NodeJS.Platform, run: () => Promise): Promise { + const original = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) + try { + return await run() + } finally { + if (original) { + Object.defineProperty(process, 'platform', original) + } + } +} + describe('registerFilesystemHandlers', () => { const store = { getRepos: () => [ @@ -466,7 +478,7 @@ describe('registerFilesystemHandlers', () => { ).resolves.toEqual({ canceled: false, destinationPath: '/downloads/report.pdf' }) const tempPath = provider.downloadFile.mock.calls[0][1] - expect(tempPath).toContain('/downloads') + expect(path.dirname(tempPath)).toBe(path.normalize('/downloads')) expect(provider.downloadFile).toHaveBeenCalledWith('/remote/report.pdf', tempPath) expect(renameMock).toHaveBeenCalledWith(tempPath, '/downloads/report.pdf') expect(rmMock).not.toHaveBeenCalledWith(tempPath, expect.anything()) @@ -816,7 +828,11 @@ describe('registerFilesystemHandlers', () => { // Why: validateGitRelativeFilePath uses path.relative() which produces // platform-specific separators (backslashes on Windows). - expect(stageFileMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, path.join('src', 'file.ts')) + expect(stageFileMock).toHaveBeenCalledWith( + WORKTREE_FEATURE_PATH, + path.join('src', 'file.ts'), + {} + ) }) it('uses worktree roots seeded by worktrees:list without rebuilding the cache', async () => { @@ -892,10 +908,11 @@ describe('registerFilesystemHandlers', () => { }) ).resolves.toEqual(['build/output.js']) - expect(checkIgnoredPathsMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, [ - path.join('dist', 'bundle.js'), - path.join('src', 'index.ts') - ]) + expect(checkIgnoredPathsMock).toHaveBeenCalledWith( + WORKTREE_FEATURE_PATH, + [path.join('dist', 'bundle.js'), path.join('src', 'index.ts')], + {} + ) expect(sshProvider.checkIgnoredPaths).toHaveBeenCalledWith('/remote/repo', [ path.join('build', 'output.js') ]) @@ -917,7 +934,7 @@ describe('registerFilesystemHandlers', () => { connectionId: 'ssh-1' }) - expect(abortMergeMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH) + expect(abortMergeMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, {}) expect(sshProvider.abortMerge).toHaveBeenCalledWith('/remote/repo') }) @@ -937,7 +954,7 @@ describe('registerFilesystemHandlers', () => { connectionId: 'ssh-1' }) - expect(abortRebaseMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH) + expect(abortRebaseMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, {}) expect(sshProvider.abortRebase).toHaveBeenCalledWith('/remote/repo') }) @@ -978,10 +995,11 @@ describe('registerFilesystemHandlers', () => { filePaths: ['./src/../src/file.ts', 'nested//child.ts'] }) - expect(bulkStageFilesMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, [ - path.join('src', 'file.ts'), - path.join('nested', 'child.ts') - ]) + expect(bulkStageFilesMock).toHaveBeenCalledWith( + WORKTREE_FEATURE_PATH, + [path.join('src', 'file.ts'), path.join('nested', 'child.ts')], + {} + ) }) it('normalizes git file paths for bulk discard requests', async () => { @@ -994,10 +1012,11 @@ describe('registerFilesystemHandlers', () => { filePaths: ['./src/../src/file.ts', 'nested//child.ts'] }) - expect(bulkDiscardChangesMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, [ - path.join('src', 'file.ts'), - path.join('nested', 'child.ts') - ]) + expect(bulkDiscardChangesMock).toHaveBeenCalledWith( + WORKTREE_FEATURE_PATH, + [path.join('src', 'file.ts'), path.join('nested', 'child.ts')], + {} + ) }) it('rejects bulk unstage requests that escape the selected worktree', async () => { @@ -1176,7 +1195,7 @@ describe('registerFilesystemHandlers', () => { baseRef: 'origin/main' }) - expect(getBranchCompareMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, 'origin/main') + expect(getBranchCompareMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, 'origin/main', {}) }) it('routes local git:commit through commitChanges and returns success', async () => { @@ -1191,7 +1210,7 @@ describe('registerFilesystemHandlers', () => { }) ).resolves.toEqual({ success: true }) - expect(commitChangesMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, 'feat: ship commit') + expect(commitChangesMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, 'feat: ship commit', {}) }) it('returns local commit hook failure payload from git:commit', async () => { @@ -1229,7 +1248,7 @@ describe('registerFilesystemHandlers', () => { }) ).resolves.toEqual({ success: true, message: 'Update README' }) - expect(getStagedCommitContextMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH) + expect(getStagedCommitContextMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, {}) expect(generateCommitMessageFromContextMock).toHaveBeenCalledWith(context, params, { kind: 'local', cwd: WORKTREE_FEATURE_PATH @@ -1340,6 +1359,71 @@ describe('registerFilesystemHandlers', () => { ) }) + it('routes local WSL project commit-message generation through the project runtime target', async () => { + await withPlatform('win32', async () => { + const context = { + branch: 'feature/ai', + stagedSummary: 'M\tREADME.md', + stagedPatch: '+hello' + } + const params = { agentId: 'codex', model: 'gpt-5.4-mini', thinkingLevel: 'low' } + const prepareForCodexLaunch = vi.fn(() => '\\\\wsl.localhost\\Ubuntu\\home\\tester\\.codex') + resolveCommitMessageSettingsMock.mockReturnValue({ ok: true, params }) + getStagedCommitContextMock.mockResolvedValue(context) + generateCommitMessageFromContextMock.mockResolvedValue({ + success: true, + message: 'Update README' + }) + const wslStore = { + ...store, + getRepos: () => [ + { + id: 'repo-1', + path: WORKTREE_FEATURE_PATH, + displayName: 'repo', + badgeColor: '#000', + addedAt: 0 + } + ], + getProjects: () => [ + { + id: 'project-1', + sourceRepoIds: ['repo-1'], + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + } + ], + getSettings: () => ({ + workspaceDir: WORKSPACE_DIR, + localWindowsRuntimeDefault: { kind: 'windows-host' } + }) + } + + registerFilesystemHandlers(wslStore as never, { prepareForCodexLaunch }) + + await handlers.get('git:generateCommitMessage')!(null, { + worktreePath: WORKTREE_FEATURE_PATH + }) + + expect(getStagedCommitContextMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, { + wslDistro: 'Ubuntu' + }) + expect(prepareForCodexLaunch).toHaveBeenCalledWith({ + runtime: 'wsl', + wslDistro: 'Ubuntu' + }) + expect(generateCommitMessageFromContextMock).toHaveBeenCalledWith( + context, + params, + expect.objectContaining({ + kind: 'local', + cwd: WORKTREE_FEATURE_PATH, + wslDistro: 'Ubuntu', + env: expect.objectContaining({ CODEX_HOME: '/home/tester/.codex' }) + }) + ) + }) + }) + it('returns a sanitized error when local agent account preparation fails', async () => { const context = { branch: 'feature/ai', @@ -1447,6 +1531,66 @@ describe('registerFilesystemHandlers', () => { ) }) + it('routes local WSL project model discovery through the project runtime target', async () => { + await withPlatform('win32', async () => { + discoverCommitMessageModelsLocalMock.mockResolvedValue({ + success: true, + capability: { + id: 'codex', + label: 'Codex', + modelSource: 'dynamic', + defaultModelId: 'gpt-5.5', + models: [{ id: 'gpt-5.5', label: 'GPT-5.5' }] + }, + models: [{ id: 'gpt-5.5', label: 'GPT-5.5' }], + defaultModelId: 'gpt-5.5' + }) + const prepareForCodexLaunch = vi.fn(() => '\\\\wsl.localhost\\Ubuntu\\home\\tester\\.codex') + const wslStore = { + ...store, + getRepos: () => [ + { + id: 'repo-1', + path: WORKTREE_FEATURE_PATH, + displayName: 'repo', + badgeColor: '#000', + addedAt: 0 + } + ], + getProjects: () => [ + { + id: 'project-1', + sourceRepoIds: ['repo-1'], + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + } + ], + getSettings: () => ({ + workspaceDir: WORKSPACE_DIR, + agentCmdOverrides: { codex: 'npx codex' }, + localWindowsRuntimeDefault: { kind: 'windows-host' } + }) + } + + registerFilesystemHandlers(wslStore as never, { prepareForCodexLaunch }) + + await handlers.get('git:discoverCommitMessageModels')!(null, { + agentId: 'codex', + worktreePath: WORKTREE_FEATURE_PATH + }) + + expect(prepareForCodexLaunch).toHaveBeenCalledWith({ + runtime: 'wsl', + wslDistro: 'Ubuntu' + }) + expect(discoverCommitMessageModelsLocalMock).toHaveBeenCalledWith( + 'codex', + expect.objectContaining({ CODEX_HOME: '/home/tester/.codex' }), + 'npx codex', + { cwd: WORKTREE_FEATURE_PATH, wslDistro: 'Ubuntu' } + ) + }) + }) + it('routes SSH model discovery through the remote git provider', async () => { discoverCommitMessageModelsRemoteMock.mockResolvedValue({ success: true, @@ -1809,7 +1953,7 @@ describe('registerFilesystemHandlers', () => { baseRef: 'origin/main' }) - expect(getBranchCompareMock).toHaveBeenCalledWith(externalWorktreePath, 'origin/main') + expect(getBranchCompareMock).toHaveBeenCalledWith(externalWorktreePath, 'origin/main', {}) }) it('rejects branchCompare for a worktree added after cache was built, then succeeds after invalidation', async () => { @@ -1876,7 +2020,7 @@ describe('registerFilesystemHandlers', () => { baseRef: 'origin/main' }) - expect(getBranchCompareMock).toHaveBeenCalledWith(cliWorktreePath, 'origin/main') + expect(getBranchCompareMock).toHaveBeenCalledWith(cliWorktreePath, 'origin/main', {}) }) it('routes branch diff queries through the pinned branch diff helper', async () => { @@ -1904,12 +2048,16 @@ describe('registerFilesystemHandlers', () => { // Why: validateGitRelativeFilePath uses path.relative() which produces // platform-specific separators (backslashes on Windows). - expect(getBranchDiffMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, { - headOid: 'head-oid', - mergeBase: 'merge-base-oid', - filePath: path.join('src', 'file.ts'), - oldPath: path.join('src', 'old-file.ts') - }) + expect(getBranchDiffMock).toHaveBeenCalledWith( + WORKTREE_FEATURE_PATH, + { + headOid: 'head-oid', + mergeBase: 'merge-base-oid', + filePath: path.join('src', 'file.ts'), + oldPath: path.join('src', 'old-file.ts') + }, + {} + ) }) // Why: the original SSH Quick Open bug had two halves — relay-side policy diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 0d0a75d4023..6f803ca737c 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -63,6 +63,7 @@ import { generatePullRequestFieldsFromContext, resolveCommitMessageSettings, type DiscoverCommitMessageModelsResult, + type CommitMessageGenerationTarget, type GenerateCommitMessageResult, type GeneratePullRequestFieldsResult } from '../text-generation/commit-message-text-generation' @@ -92,6 +93,7 @@ import { import { listQuickOpenFiles } from './filesystem-list-files' import { registerFilesystemMutationHandlers } from './filesystem-mutations' import { searchWithGitGrep } from './filesystem-search-git' +import { getLocalGitOptionsForRegisteredWorktree } from './local-worktree-runtime-options' import { listMarkdownDocuments, markdownDocumentsFromRelativePaths } from './markdown-documents' import { checkRgAvailable } from './rg-availability' import { @@ -105,11 +107,13 @@ import { import { resolveHostedReviewBodyForGeneration } from '../source-control/pull-request-template' import { prepareLocalCommitMessageAgentEnv, + type CommitMessageAgentRuntimeTarget, type CommitMessageAgentEnvironmentResolvers } from '../text-generation/commit-message-agent-environment' import { listRepoWorktrees } from '../repo-worktrees' import { splitWorktreeId } from '../../shared/worktree-id' import { getRuntimePathBasename } from '../../shared/cross-platform-path' +import type { LocalProjectWorktreeGitOptions } from '../project-runtime-git-options' // Why: Monaco has large-file optimizations like VS Code; blocking at 5MB makes // ordinary JSON/log files inaccessible before the editor can degrade features. @@ -228,10 +232,6 @@ function comparableLocalPath(value: string): string { return process.platform === 'win32' ? normalized.toLowerCase() : normalized } -function comparableRemotePath(value: string): string { - return value.replace(/[/\\]+$/g, '') -} - function getCandidateLocalWorktreePaths( worktreePath: string, resolvedWorktreePath: string @@ -253,6 +253,10 @@ function hasRegisteredWorktreeMetaForRepo( return false } +function comparableRemotePath(value: string): string { + return value.replace(/[/\\]+$/g, '') +} + function hasRegisteredRemoteWorktreeMetaForRepo( store: Store, repoId: string, @@ -350,6 +354,27 @@ async function getRepoForSourceControlAi( return (await localRepoOwnsWorktree(store, repo, args.worktreePath)) ? repo : null } +function getLocalAgentRuntimeTarget( + gitOptions: LocalProjectWorktreeGitOptions +): CommitMessageAgentRuntimeTarget { + return gitOptions.wslDistro + ? { runtime: 'wsl', wslDistro: gitOptions.wslDistro } + : { runtime: 'host' } +} + +function getLocalTextGenerationTarget( + worktreePath: string, + gitOptions: LocalProjectWorktreeGitOptions, + env?: NodeJS.ProcessEnv +): Extract { + return { + kind: 'local', + cwd: worktreePath, + ...(gitOptions.wslDistro ? { wslDistro: gitOptions.wslDistro } : {}), + ...(env ? { env } : {}) + } +} + function validateFullGitObjectId(value: string, label: string): string { if (!FULL_GIT_OBJECT_ID_PATTERN.test(value)) { throw new Error(`${label} must be a full git object id`) @@ -660,6 +685,11 @@ export function registerFilesystemHandlers( return provider.search(args) } const rootPath = await resolveAuthorizedPath(args.rootPath, store) + const localGitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.rootPath, + rootPath + ) const maxResults = Math.max( 1, Math.min(args.maxResults ?? DEFAULT_SEARCH_MAX_RESULTS, DEFAULT_SEARCH_MAX_RESULTS) @@ -670,9 +700,9 @@ export function registerFilesystemHandlers( // spawn('rg') emits 'close' before 'error' on some platforms, causing // the handler to resolve with empty results before the git-grep // fallback can run. The result is cached after the first check. - const rgAvailable = await checkRgAvailable(rootPath) + const rgAvailable = await checkRgAvailable(rootPath, localGitOptions.wslDistro) if (!rgAvailable) { - return searchWithGitGrep(rootPath, args, maxResults) + return searchWithGitGrep(rootPath, args, maxResults, localGitOptions) } return new Promise((resolvePromise) => { @@ -691,12 +721,11 @@ export function registerFilesystemHandlers( let child: ChildProcess | null = null let killTimeout: ReturnType - // Why: when rg runs inside WSL, output paths are Linux-native - // (e.g. /home/user/repo/src/file.ts). Translate them back to - // Windows UNC paths so path.relative() and Node fs APIs work. - const wslInfo = parseWslPath(rootPath) - const transformAbsPath = wslInfo - ? (p: string): string => toWindowsWslPath(p, wslInfo.distro) + // Why: WSL-routed rg emits Linux-native paths. UNC repos carry their + // distro in the path; Windows-path repos carry it in project runtime. + const wslDistroForOutput = parseWslPath(rootPath)?.distro ?? localGitOptions.wslDistro + const transformAbsPath = wslDistroForOutput + ? (p: string): string => (p.startsWith('/') ? toWindowsWslPath(p, wslDistroForOutput) : p) : undefined const resolveOnce = (): void => { @@ -726,6 +755,7 @@ export function registerFilesystemHandlers( const nextChild = wslAwareSpawn('rg', rgArgs, { cwd: rootPath, + ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), stdio: ['ignore', 'pipe', 'pipe'] }) child = nextChild @@ -810,7 +840,12 @@ export function registerFilesystemHandlers( return provider.getStatus(args.worktreePath, options) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - return getStatus(worktreePath, options) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return getStatus(worktreePath, { ...options, ...gitOptions }) } ) @@ -830,7 +865,12 @@ export function registerFilesystemHandlers( } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const paths = args.paths.map((p) => validateGitRelativeFilePath(worktreePath, p)) - return checkIgnoredPaths(worktreePath, paths) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return checkIgnoredPaths(worktreePath, paths, gitOptions) } ) @@ -842,7 +882,12 @@ export function registerFilesystemHandlers( 'git:findHugeFoldersToIgnore', async (_event, args: { worktreePath: string }): Promise => { const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - return findKnownHugeFolderPathsToIgnore(worktreePath) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return findKnownHugeFolderPathsToIgnore(worktreePath, gitOptions) } ) @@ -869,7 +914,12 @@ export function registerFilesystemHandlers( return provider.getHistory(args.worktreePath, options) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - return getHistory(worktreePath, options) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return getHistory(worktreePath, { ...options, ...gitOptions }) } ) @@ -905,7 +955,12 @@ export function registerFilesystemHandlers( return provider.abortMerge(args.worktreePath) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - await abortMerge(worktreePath) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + await abortMerge(worktreePath, gitOptions) } ) @@ -920,7 +975,12 @@ export function registerFilesystemHandlers( return provider.abortRebase(args.worktreePath) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - await abortRebase(worktreePath) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + await abortRebase(worktreePath, gitOptions) } ) @@ -950,7 +1010,12 @@ export function registerFilesystemHandlers( } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) - return getDiff(worktreePath, filePath, args.staged, args.compareAgainstHead) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return getDiff(worktreePath, filePath, args.staged, args.compareAgainstHead, gitOptions) } ) @@ -972,7 +1037,12 @@ export function registerFilesystemHandlers( return provider.commit(args.worktreePath, args.message) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - return commitChanges(worktreePath, args.message) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return commitChanges(worktreePath, args.message, gitOptions) } ) @@ -1039,9 +1109,14 @@ export function registerFilesystemHandlers( }) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) let context try { - context = await getStagedCommitContext(worktreePath) + context = await getStagedCommitContext(worktreePath, gitOptions) } catch (error) { console.error('[filesystem] Failed to read staged commit context:', error) return { @@ -1054,16 +1129,17 @@ export function registerFilesystemHandlers( } const localEnv = await prepareLocalCommitMessageAgentEnv( resolvedSettings.params.agentId, - commitMessageAgentEnv + commitMessageAgentEnv, + getLocalAgentRuntimeTarget(gitOptions) ) if (!localEnv.ok) { return { success: false, error: localEnv.error } } - return generateCommitMessageFromContext(context, resolvedSettings.params, { - kind: 'local', - cwd: worktreePath, - ...(localEnv.env ? { env: localEnv.env } : {}) - }) + return generateCommitMessageFromContext( + context, + resolvedSettings.params, + getLocalTextGenerationTarget(worktreePath, gitOptions, localEnv.env) + ) } ) @@ -1109,15 +1185,36 @@ export function registerFilesystemHandlers( agentCommandOverride ) } - const localEnv = await prepareLocalCommitMessageAgentEnv(agentId, commitMessageAgentEnv) + let localRuntimeTarget: CommitMessageAgentRuntimeTarget = { runtime: 'host' } + let localDiscoveryOptions: Parameters[3] + if (args.worktreePath) { + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + localRuntimeTarget = getLocalAgentRuntimeTarget(gitOptions) + localDiscoveryOptions = gitOptions.wslDistro + ? { cwd: worktreePath, wslDistro: gitOptions.wslDistro } + : { cwd: worktreePath } + } + const localEnv = await prepareLocalCommitMessageAgentEnv( + agentId, + commitMessageAgentEnv, + localRuntimeTarget + ) if (!localEnv.ok) { return { success: false, error: localEnv.error } } - return discoverCommitMessageModelsLocal( - agentId as TuiAgent, - localEnv.env, - agentCommandOverride - ) + return localDiscoveryOptions + ? discoverCommitMessageModelsLocal( + agentId as TuiAgent, + localEnv.env, + agentCommandOverride, + localDiscoveryOptions + ) + : discoverCommitMessageModelsLocal(agentId as TuiAgent, localEnv.env, agentCommandOverride) } ) @@ -1206,6 +1303,11 @@ export function registerFilesystemHandlers( } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) let context: Awaited> try { const currentBody = await resolveHostedReviewBodyForGeneration({ @@ -1216,7 +1318,8 @@ export function registerFilesystemHandlers( useTemplate: args.useTemplate }) context = await getPullRequestDraftContext( - (argv, options) => gitExecFileAsync(argv, { cwd: worktreePath, ...options }), + (argv, options) => + gitExecFileAsync(argv, { cwd: worktreePath, ...gitOptions, ...options }), { base: args.base, currentTitle: args.title, @@ -1235,16 +1338,17 @@ export function registerFilesystemHandlers( } const localEnv = await prepareLocalCommitMessageAgentEnv( resolvedSettings.params.agentId, - commitMessageAgentEnv + commitMessageAgentEnv, + getLocalAgentRuntimeTarget(gitOptions) ) if (!localEnv.ok) { return { success: false, error: localEnv.error } } - return generatePullRequestFieldsFromContext(context, resolvedSettings.params, { - kind: 'local', - cwd: worktreePath, - ...(localEnv.env ? { env: localEnv.env } : {}) - }) + return generatePullRequestFieldsFromContext( + context, + resolvedSettings.params, + getLocalTextGenerationTarget(worktreePath, gitOptions, localEnv.env) + ) } ) @@ -1278,7 +1382,12 @@ export function registerFilesystemHandlers( return provider.getBranchCompare(args.worktreePath, args.baseRef) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - return getBranchCompare(worktreePath, args.baseRef) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return getBranchCompare(worktreePath, args.baseRef, gitOptions) } ) @@ -1297,7 +1406,12 @@ export function registerFilesystemHandlers( return provider.getCommitCompare(args.worktreePath, commitId) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - return getCommitCompare(worktreePath, commitId) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return getCommitCompare(worktreePath, commitId, gitOptions) } ) @@ -1318,7 +1432,12 @@ export function registerFilesystemHandlers( return provider.getUpstreamStatus(args.worktreePath, args.pushTarget) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - return getUpstreamStatus(worktreePath, args.pushTarget) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return getUpstreamStatus(worktreePath, args.pushTarget, gitOptions) } ) @@ -1339,10 +1458,15 @@ export function registerFilesystemHandlers( return provider.fetchRemote(args.worktreePath, args.pushTarget) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) if (args.pushTarget) { - await validateGitPushTarget(worktreePath, args.pushTarget) + await validateGitPushTarget(worktreePath, args.pushTarget, gitOptions) } - await gitFetch(worktreePath, args.pushTarget) + await gitFetch(worktreePath, args.pushTarget, gitOptions) } ) @@ -1367,7 +1491,12 @@ export function registerFilesystemHandlers( return provider.syncForkDefaultBranch(args.worktreePath, expectedUpstream) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - return gitSyncForkDefaultBranch(worktreePath, expectedUpstream) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return gitSyncForkDefaultBranch(worktreePath, expectedUpstream, gitOptions) } ) @@ -1400,11 +1529,17 @@ export function registerFilesystemHandlers( }) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) if (args.pushTarget) { - await validateGitPushTarget(worktreePath, args.pushTarget) + await validateGitPushTarget(worktreePath, args.pushTarget, gitOptions) } await gitPush(worktreePath, publish, args.pushTarget, { - forceWithLease: args.forceWithLease === true + forceWithLease: args.forceWithLease === true, + ...gitOptions }) } ) @@ -1426,10 +1561,15 @@ export function registerFilesystemHandlers( return provider.pullBranch(args.worktreePath, args.pushTarget) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) if (args.pushTarget) { - await validateGitPushTarget(worktreePath, args.pushTarget) + await validateGitPushTarget(worktreePath, args.pushTarget, gitOptions) } - await gitPull(worktreePath, args.pushTarget) + await gitPull(worktreePath, args.pushTarget, gitOptions) } ) @@ -1450,10 +1590,15 @@ export function registerFilesystemHandlers( return provider.fastForwardBranch(args.worktreePath, args.pushTarget) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) if (args.pushTarget) { - await validateGitPushTarget(worktreePath, args.pushTarget) + await validateGitPushTarget(worktreePath, args.pushTarget, gitOptions) } - await gitFastForward(worktreePath, args.pushTarget) + await gitFastForward(worktreePath, args.pushTarget, gitOptions) } ) @@ -1471,7 +1616,12 @@ export function registerFilesystemHandlers( return provider.rebaseFromBase(args.worktreePath, args.baseRef) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - await gitPullRebaseFromBase(worktreePath, args.baseRef) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + await gitPullRebaseFromBase(worktreePath, args.baseRef, gitOptions) } ) @@ -1517,12 +1667,21 @@ export function registerFilesystemHandlers( const oldPath = args.oldPath ? validateGitRelativeFilePath(worktreePath, args.oldPath) : undefined - return getBranchDiff(worktreePath, { - mergeBase: args.compare.mergeBase, - headOid: args.compare.headOid, - filePath, - oldPath - }) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return getBranchDiff( + worktreePath, + { + mergeBase: args.compare.mergeBase, + headOid: args.compare.headOid, + filePath, + oldPath + }, + gitOptions + ) } ) @@ -1558,12 +1717,21 @@ export function registerFilesystemHandlers( const oldPath = args.oldPath ? validateGitRelativeFilePath(worktreePath, args.oldPath) : undefined - return getCommitDiff(worktreePath, { - commitOid, - parentOid, - filePath, - oldPath - }) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return getCommitDiff( + worktreePath, + { + commitOid, + parentOid, + filePath, + oldPath + }, + gitOptions + ) } ) @@ -1582,7 +1750,12 @@ export function registerFilesystemHandlers( } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) - await stageFile(worktreePath, filePath) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + await stageFile(worktreePath, filePath, gitOptions) } ) @@ -1601,7 +1774,12 @@ export function registerFilesystemHandlers( } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) - await unstageFile(worktreePath, filePath) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + await unstageFile(worktreePath, filePath, gitOptions) } ) @@ -1620,7 +1798,12 @@ export function registerFilesystemHandlers( } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) - await discardChanges(worktreePath, filePath) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + await discardChanges(worktreePath, filePath, gitOptions) } ) @@ -1639,7 +1822,12 @@ export function registerFilesystemHandlers( } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const filePaths = args.filePaths.map((p) => validateGitRelativeFilePath(worktreePath, p)) - await bulkDiscardChanges(worktreePath, filePaths) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + await bulkDiscardChanges(worktreePath, filePaths, gitOptions) } ) @@ -1658,7 +1846,12 @@ export function registerFilesystemHandlers( } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const filePaths = args.filePaths.map((p) => validateGitRelativeFilePath(worktreePath, p)) - await bulkStageFiles(worktreePath, filePaths) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + await bulkStageFiles(worktreePath, filePaths, gitOptions) } ) @@ -1677,7 +1870,12 @@ export function registerFilesystemHandlers( } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const filePaths = args.filePaths.map((p) => validateGitRelativeFilePath(worktreePath, p)) - await bulkUnstageFiles(worktreePath, filePaths) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + await bulkUnstageFiles(worktreePath, filePaths, gitOptions) } ) diff --git a/src/main/ipc/github-work-item-args.test.ts b/src/main/ipc/github-work-item-args.test.ts index b979bd40a88..234906a376e 100644 --- a/src/main/ipc/github-work-item-args.test.ts +++ b/src/main/ipc/github-work-item-args.test.ts @@ -33,19 +33,19 @@ describe('dispatchWorkItem', () => { type: 'bogus' as unknown as 'issue' | 'pr' } await dispatchWorkItem(bogus, repo, fn) - expect(fn).toHaveBeenCalledWith('/r', 42, undefined, null) + expect(fn).toHaveBeenCalledWith('/r', 42, undefined, null, undefined) }) it('passes valid issue type through', async () => { const fn = vi.fn().mockResolvedValue(null) await dispatchWorkItem({ repoPath: '/r', number: 42, type: 'issue' }, repo, fn) - expect(fn).toHaveBeenCalledWith('/r', 42, 'issue', null) + expect(fn).toHaveBeenCalledWith('/r', 42, 'issue', null, undefined) }) it('passes valid pr type through', async () => { const fn = vi.fn().mockResolvedValue(null) await dispatchWorkItem({ repoPath: '/r', number: 42, type: 'pr' }, repo, fn) - expect(fn).toHaveBeenCalledWith('/r', 42, 'pr', null) + expect(fn).toHaveBeenCalledWith('/r', 42, 'pr', null, undefined) }) it('passes SSH connection context through', async () => { @@ -55,6 +55,6 @@ describe('dispatchWorkItem', () => { { path: '/remote/repo', connectionId: 'ssh-1' }, fn ) - expect(fn).toHaveBeenCalledWith('/remote/repo', 42, 'issue', 'ssh-1') + expect(fn).toHaveBeenCalledWith('/remote/repo', 42, 'issue', 'ssh-1', undefined) }) }) diff --git a/src/main/ipc/github-work-item-args.ts b/src/main/ipc/github-work-item-args.ts index 44c689b3aef..e3dc63b2e9e 100644 --- a/src/main/ipc/github-work-item-args.ts +++ b/src/main/ipc/github-work-item-args.ts @@ -13,6 +13,10 @@ type RegisteredRepoContext = { connectionId?: string | null } +type LocalGitExecOptions = { + wslDistro?: string +} + // Why: renderer input crosses the IPC boundary and is untrusted. Reject // non-integer or < 1 numbers, and coerce unrecognised `type` values to // undefined so getWorkItem falls through to its issue-then-PR probe rather @@ -24,13 +28,15 @@ export function dispatchWorkItem( path: string, n: number, t?: 'issue' | 'pr', - connectionId?: string | null - ) => Promise + connectionId?: string | null, + localGitOptions?: LocalGitExecOptions + ) => Promise, + localGitOptions?: LocalGitExecOptions ): Promise | null { const { number, type } = args if (typeof number !== 'number' || !Number.isInteger(number) || number < 1) { return null } const safeType = type === 'issue' || type === 'pr' ? type : undefined - return fn(repo.path, number, safeType, repo.connectionId ?? null) + return fn(repo.path, number, safeType, repo.connectionId ?? null, localGitOptions) } diff --git a/src/main/ipc/github.test.ts b/src/main/ipc/github.test.ts index 6776c8adbff..0a77e91f2f6 100644 --- a/src/main/ipc/github.test.ts +++ b/src/main/ipc/github.test.ts @@ -2,38 +2,97 @@ handler harness; keeping the related route wiring together avoids duplicated setup. */ import { beforeEach, describe, expect, it, vi } from 'vitest' +const ORIGINAL_PLATFORM = process.platform + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { + configurable: true, + value: platform + }) +} + const { handleMock, getPRForBranchMock, getIssueMock, + getWorkItemMock, + getWorkItemByOwnerRepoMock, + getWorkItemDetailsMock, + getPRFileContentsMock, + getPRChecksMock, + getPRCheckDetailsMock, + getPRCommentsMock, + resolveReviewThreadMock, + setPRFileViewedMock, + addPRReviewCommentMock, + addPRReviewCommentReplyMock, + updatePRTitleMock, listIssuesMock, listWorkItemsMock, + countWorkItemsMock, + createIssueMock, + updateIssueMock, + addIssueCommentMock, listLabelsMock, listAssignableUsersMock, getAuthenticatedViewerMock, mergePRMock, setPRAutoMergeMock, + updatePRStateMock, + rerunPRChecksMock, + requestPRReviewersMock, + removePRReviewersMock, checkOrcaStarredMock, starOrcaMock, trackMock, getCohortAtEmitMock, - getAllWebContentsMock + getAllWebContentsMock, + clearVisiblePRRefreshWindowMock, + enqueuePRRefreshMock, + refreshPRNowMock, + reportVisiblePRRefreshCandidatesMock, + setPRRefreshOutcomeObserverMock } = vi.hoisted(() => ({ handleMock: vi.fn(), getPRForBranchMock: vi.fn(), getIssueMock: vi.fn(), + getWorkItemMock: vi.fn(), + getWorkItemByOwnerRepoMock: vi.fn(), + getWorkItemDetailsMock: vi.fn(), + getPRFileContentsMock: vi.fn(), + getPRChecksMock: vi.fn(), + getPRCheckDetailsMock: vi.fn(), + getPRCommentsMock: vi.fn(), + resolveReviewThreadMock: vi.fn(), + setPRFileViewedMock: vi.fn(), + addPRReviewCommentMock: vi.fn(), + addPRReviewCommentReplyMock: vi.fn(), + updatePRTitleMock: vi.fn(), listIssuesMock: vi.fn(), listWorkItemsMock: vi.fn(), + countWorkItemsMock: vi.fn(), + createIssueMock: vi.fn(), + updateIssueMock: vi.fn(), + addIssueCommentMock: vi.fn(), listLabelsMock: vi.fn(), listAssignableUsersMock: vi.fn(), getAuthenticatedViewerMock: vi.fn(), mergePRMock: vi.fn(), setPRAutoMergeMock: vi.fn(), + updatePRStateMock: vi.fn(), + rerunPRChecksMock: vi.fn(), + requestPRReviewersMock: vi.fn(), + removePRReviewersMock: vi.fn(), checkOrcaStarredMock: vi.fn(), starOrcaMock: vi.fn(), trackMock: vi.fn(), getCohortAtEmitMock: vi.fn(), - getAllWebContentsMock: vi.fn() + getAllWebContentsMock: vi.fn(), + clearVisiblePRRefreshWindowMock: vi.fn(), + enqueuePRRefreshMock: vi.fn(), + refreshPRNowMock: vi.fn(), + reportVisiblePRRefreshCandidatesMock: vi.fn(), + setPRRefreshOutcomeObserverMock: vi.fn() })) vi.mock('electron', () => ({ @@ -48,17 +107,48 @@ vi.mock('electron', () => ({ vi.mock('../github/client', () => ({ getPRForBranch: getPRForBranchMock, getIssue: getIssueMock, + getWorkItem: getWorkItemMock, + getWorkItemByOwnerRepo: getWorkItemByOwnerRepoMock, listIssues: listIssuesMock, listWorkItems: listWorkItemsMock, + countWorkItems: countWorkItemsMock, + createIssue: createIssueMock, + updateIssue: updateIssueMock, + addIssueComment: addIssueCommentMock, listLabels: listLabelsMock, listAssignableUsers: listAssignableUsersMock, getAuthenticatedViewer: getAuthenticatedViewerMock, + getPRChecks: getPRChecksMock, + getPRCheckDetails: getPRCheckDetailsMock, + getPRComments: getPRCommentsMock, + resolveReviewThread: resolveReviewThreadMock, + setPRFileViewed: setPRFileViewedMock, + addPRReviewComment: addPRReviewCommentMock, + addPRReviewCommentReply: addPRReviewCommentReplyMock, + updatePRTitle: updatePRTitleMock, mergePR: mergePRMock, setPRAutoMerge: setPRAutoMergeMock, + updatePRState: updatePRStateMock, + rerunPRChecks: rerunPRChecksMock, + requestPRReviewers: requestPRReviewersMock, + removePRReviewers: removePRReviewersMock, checkOrcaStarred: checkOrcaStarredMock, starOrca: starOrcaMock })) +vi.mock('../github/work-item-details', () => ({ + getWorkItemDetails: getWorkItemDetailsMock, + getPRFileContents: getPRFileContentsMock +})) + +vi.mock('../github/pr-refresh-coordinator', () => ({ + clearVisiblePRRefreshWindow: clearVisiblePRRefreshWindowMock, + enqueuePRRefresh: enqueuePRRefreshMock, + refreshPRNow: refreshPRNowMock, + reportVisiblePRRefreshCandidates: reportVisiblePRRefreshCandidatesMock, + setPRRefreshOutcomeObserver: setPRRefreshOutcomeObserverMock +})) + vi.mock('../telemetry/client', () => ({ track: trackMock })) @@ -84,8 +174,19 @@ describe('registerGitHubHandlers', () => { issueSourcePreference?: 'origin' | 'upstream' } let repos: FixtureRepo[] = [] + let projects: { + id: string + displayName: string + badgeColor: string + sourceRepoIds: string[] + localWindowsRuntimePreference?: { kind: 'wsl'; distro: string } + createdAt: number + updatedAt: number + }[] = [] const store = { - getRepos: () => repos + getRepos: () => repos, + getProjects: () => projects, + getSettings: () => ({ localWindowsRuntimeDefault: { kind: 'windows-host' } }) } const stats = { hasCountedPR: () => false, @@ -93,16 +194,37 @@ describe('registerGitHubHandlers', () => { } beforeEach(() => { + setPlatform(ORIGINAL_PLATFORM) handleMock.mockReset() getPRForBranchMock.mockReset() getIssueMock.mockReset() + getWorkItemMock.mockReset() + getWorkItemByOwnerRepoMock.mockReset() + getWorkItemDetailsMock.mockReset() + getPRFileContentsMock.mockReset() + getPRChecksMock.mockReset() + getPRCheckDetailsMock.mockReset() + getPRCommentsMock.mockReset() + resolveReviewThreadMock.mockReset() + setPRFileViewedMock.mockReset() + addPRReviewCommentMock.mockReset() + addPRReviewCommentReplyMock.mockReset() + updatePRTitleMock.mockReset() listIssuesMock.mockReset() listWorkItemsMock.mockReset() + countWorkItemsMock.mockReset() + createIssueMock.mockReset() + updateIssueMock.mockReset() + addIssueCommentMock.mockReset() listLabelsMock.mockReset() listAssignableUsersMock.mockReset() getAuthenticatedViewerMock.mockReset() mergePRMock.mockReset() setPRAutoMergeMock.mockReset() + updatePRStateMock.mockReset() + rerunPRChecksMock.mockReset() + requestPRReviewersMock.mockReset() + removePRReviewersMock.mockReset() checkOrcaStarredMock.mockReset() starOrcaMock.mockReset() trackMock.mockReset() @@ -110,6 +232,11 @@ describe('registerGitHubHandlers', () => { getCohortAtEmitMock.mockReturnValue({ nth_repo_added: undefined }) getAllWebContentsMock.mockReset() getAllWebContentsMock.mockReturnValue([]) + clearVisiblePRRefreshWindowMock.mockReset() + enqueuePRRefreshMock.mockReset() + refreshPRNowMock.mockReset() + reportVisiblePRRefreshCandidatesMock.mockReset() + setPRRefreshOutcomeObserverMock.mockReset() for (const key of Object.keys(handlers)) { delete handlers[key] } @@ -127,6 +254,7 @@ describe('registerGitHubHandlers', () => { addedAt: 0 } ] + projects = [] handleMock.mockImplementation((channel, handler) => { handlers[channel] = handler @@ -304,6 +432,473 @@ describe('registerGitHubHandlers', () => { ) }) + it('routes local WSL project GitHub issue and work-item IPC through project git options', async () => { + setPlatform('win32') + projects = [ + { + id: 'project-1', + displayName: 'repo', + badgeColor: 'blue', + sourceRepoIds: ['repo-1'], + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }, + createdAt: 0, + updatedAt: 0 + } + ] + listIssuesMock.mockResolvedValue({ items: [] }) + listWorkItemsMock.mockResolvedValue({ items: [] }) + countWorkItemsMock.mockResolvedValue(0) + getIssueMock.mockResolvedValue(null) + createIssueMock.mockResolvedValue({ ok: true, number: 1, url: 'https://example.com/1' }) + updateIssueMock.mockResolvedValue({ ok: true }) + addIssueCommentMock.mockResolvedValue({ ok: true }) + listLabelsMock.mockResolvedValue([]) + listAssignableUsersMock.mockResolvedValue([]) + getPRForBranchMock.mockResolvedValue(null) + registerGitHubHandlers(store as never, stats as never) + const localGitOptions = { wslDistro: 'Ubuntu' } + + await handlers['gh:prForBranch'](null, { + repoPath: '/workspace/repo', + branch: 'feature/wsl' + }) + await handlers['gh:reportVisiblePRRefreshCandidates']( + { sender: { id: 7, once: vi.fn() } }, + { + generation: 1, + candidates: [ + { + cacheKey: '/workspace/repo::feature/wsl', + repoPath: '/workspace/repo', + branch: 'feature/wsl', + repoKind: 'git', + repoId: 'repo-1' + } + ] + } + ) + await handlers['gh:listWorkItems'](null, { + repoPath: '/workspace/repo', + limit: 10, + query: 'is:open', + before: 'cursor-1', + noCache: true + }) + await handlers['gh:countWorkItems'](null, { + repoPath: '/workspace/repo', + query: 'is:issue' + }) + await handlers['gh:listIssues'](null, { repoPath: '/workspace/repo', limit: 5 }) + await handlers['gh:issue'](null, { repoPath: '/workspace/repo', number: 7 }) + await handlers['gh:createIssue'](null, { + repoPath: '/workspace/repo', + title: 'Title', + body: 'Body', + labels: ['bug'] + }) + await handlers['gh:updateIssue']( + { sender: { id: 1 } }, + { + repoPath: '/workspace/repo', + number: 7, + updates: { body: 'Updated' } + } + ) + await handlers['gh:addIssueComment']( + { sender: { id: 1 } }, + { + repoPath: '/workspace/repo', + number: 7, + body: 'Comment' + } + ) + await handlers['gh:listLabels'](null, { repoPath: '/workspace/repo' }) + await handlers['gh:listAssignableUsers'](null, { repoPath: '/workspace/repo' }) + + expect(getPRForBranchMock).toHaveBeenCalledWith( + '/workspace/repo', + 'feature/wsl', + null, + null, + null, + { localGitExecOptions: localGitOptions } + ) + expect(reportVisiblePRRefreshCandidatesMock).toHaveBeenCalledWith( + [ + expect.objectContaining({ + repoPath: '/workspace/repo', + repoId: 'repo-1', + localGitOptions + }) + ], + 1, + 7 + ) + expect(listWorkItemsMock).toHaveBeenCalledWith( + '/workspace/repo', + 10, + 'is:open', + 'cursor-1', + undefined, + null, + true, + localGitOptions + ) + expect(countWorkItemsMock).toHaveBeenCalledWith( + '/workspace/repo', + 'is:issue', + undefined, + null, + localGitOptions + ) + expect(listIssuesMock).toHaveBeenCalledWith( + '/workspace/repo', + 5, + undefined, + null, + localGitOptions + ) + expect(getIssueMock).toHaveBeenCalledWith('/workspace/repo', 7, null, localGitOptions) + expect(createIssueMock).toHaveBeenCalledWith( + '/workspace/repo', + 'Title', + 'Body', + undefined, + null, + { labels: ['bug'], assignees: undefined }, + localGitOptions + ) + expect(updateIssueMock).toHaveBeenCalledWith( + '/workspace/repo', + 7, + { body: 'Updated' }, + null, + localGitOptions + ) + expect(addIssueCommentMock).toHaveBeenCalledWith( + '/workspace/repo', + 7, + 'Comment', + null, + null, + localGitOptions + ) + expect(listLabelsMock).toHaveBeenCalledWith('/workspace/repo', undefined, null, localGitOptions) + expect(listAssignableUsersMock).toHaveBeenCalledWith( + '/workspace/repo', + undefined, + null, + localGitOptions + ) + }) + + it('routes local WSL project GitHub PR detail and action IPC through project git options', async () => { + setPlatform('win32') + projects = [ + { + id: 'project-1', + displayName: 'repo', + badgeColor: 'blue', + sourceRepoIds: ['repo-1'], + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }, + createdAt: 0, + updatedAt: 0 + } + ] + const prRepo = { owner: 'acme', repo: 'orca' } + const localGitOptions = { wslDistro: 'Ubuntu' } + getWorkItemMock.mockResolvedValue(null) + getWorkItemByOwnerRepoMock.mockResolvedValue(null) + getWorkItemDetailsMock.mockResolvedValue(null) + getPRFileContentsMock.mockResolvedValue({ original: '', modified: '' }) + getPRChecksMock.mockResolvedValue([]) + getPRCheckDetailsMock.mockResolvedValue(null) + getPRCommentsMock.mockResolvedValue([]) + resolveReviewThreadMock.mockResolvedValue(true) + setPRFileViewedMock.mockResolvedValue(true) + addPRReviewCommentReplyMock.mockResolvedValue({ ok: true }) + addPRReviewCommentMock.mockResolvedValue({ ok: true }) + updatePRTitleMock.mockResolvedValue(true) + mergePRMock.mockResolvedValue({ ok: true }) + setPRAutoMergeMock.mockResolvedValue({ ok: true }) + updatePRStateMock.mockResolvedValue({ ok: true }) + rerunPRChecksMock.mockResolvedValue({ ok: true, count: 1 }) + requestPRReviewersMock.mockResolvedValue({ ok: true }) + removePRReviewersMock.mockResolvedValue({ ok: true }) + registerGitHubHandlers(store as never, stats as never) + + await handlers['gh:workItem'](null, { repoPath: '/workspace/repo', number: 42, type: 'pr' }) + await handlers['gh:workItemByOwnerRepo'](null, { + repoPath: '/workspace/repo', + owner: 'acme', + repo: 'orca', + number: 42, + type: 'pr' + }) + await handlers['gh:workItemDetails'](null, { + repoPath: '/workspace/repo', + number: 42, + type: 'pr' + }) + await handlers['gh:prFileContents'](null, { + repoPath: '/workspace/repo', + prNumber: 42, + path: 'src/app.ts', + status: 'modified', + headSha: 'head-sha', + baseSha: 'base-sha' + }) + await handlers['gh:prChecks'](null, { + repoPath: '/workspace/repo', + prNumber: 42, + headSha: 'head-sha', + prRepo, + noCache: true + }) + await handlers['gh:prCheckDetails'](null, { + repoPath: '/workspace/repo', + checkRunId: 9, + workflowRunId: 8, + checkName: 'lint', + url: 'https://example.com/check', + prRepo + }) + await handlers['gh:prComments'](null, { + repoPath: '/workspace/repo', + prNumber: 42, + prRepo, + noCache: true + }) + await handlers['gh:resolveReviewThread'](null, { + repoPath: '/workspace/repo', + threadId: 'thread-1', + resolve: true + }) + await handlers['gh:setPRFileViewed']( + { sender: { id: 1 } }, + { + repoPath: '/workspace/repo', + prNumber: 42, + pullRequestId: 'PR_kw', + path: 'src/app.ts', + viewed: true + } + ) + await handlers['gh:addPRReviewCommentReply']( + { sender: { id: 1 } }, + { + repoPath: '/workspace/repo', + prNumber: 42, + commentId: 11, + body: ' Reply ', + threadId: 'thread-1', + path: 'src/app.ts', + line: 10, + prRepo + } + ) + await handlers['gh:addPRReviewComment']( + { sender: { id: 1 } }, + { + repoPath: '/workspace/repo', + prNumber: 42, + commitId: ' head-sha ', + path: 'src/app.ts', + line: 10, + body: ' Inline ' + } + ) + await handlers['gh:updatePRTitle']( + { sender: { id: 1 } }, + { + repoPath: '/workspace/repo', + prNumber: 42, + title: 'New title', + prRepo + } + ) + await handlers['gh:mergePR']( + { sender: { id: 1 } }, + { + repoPath: '/workspace/repo', + prNumber: 42, + method: 'squash', + prRepo + } + ) + await handlers['gh:setPRAutoMerge']( + { sender: { id: 1 } }, + { + repoPath: '/workspace/repo', + prNumber: 42, + enabled: true, + method: 'squash', + prRepo + } + ) + await handlers['gh:updatePRState']( + { sender: { id: 1 } }, + { + repoPath: '/workspace/repo', + prNumber: 42, + updates: { state: 'closed' } + } + ) + await handlers['gh:rerunPRChecks'](null, { + repoPath: '/workspace/repo', + prNumber: 42, + headSha: 'head-sha', + failedOnly: true + }) + await handlers['gh:requestPRReviewers']( + { sender: { id: 1 } }, + { + repoPath: '/workspace/repo', + prNumber: 42, + reviewers: ['octo'] + } + ) + await handlers['gh:removePRReviewers']( + { sender: { id: 1 } }, + { + repoPath: '/workspace/repo', + prNumber: 42, + reviewers: ['octo'] + } + ) + + expect(getWorkItemMock).toHaveBeenCalledWith('/workspace/repo', 42, 'pr', null, localGitOptions) + expect(getWorkItemByOwnerRepoMock).toHaveBeenCalledWith( + '/workspace/repo', + prRepo, + 42, + 'pr', + null, + localGitOptions + ) + expect(getWorkItemDetailsMock).toHaveBeenCalledWith( + '/workspace/repo', + 42, + 'pr', + null, + localGitOptions + ) + expect(getPRFileContentsMock).toHaveBeenCalledWith( + expect.objectContaining({ repoPath: '/workspace/repo', localGitOptions }) + ) + expect(getPRChecksMock).toHaveBeenCalledWith( + '/workspace/repo', + 42, + 'head-sha', + prRepo, + { noCache: true }, + null, + localGitOptions + ) + expect(getPRCheckDetailsMock).toHaveBeenCalledWith( + '/workspace/repo', + { + checkRunId: 9, + workflowRunId: 8, + checkName: 'lint', + url: 'https://example.com/check', + prRepo + }, + null, + localGitOptions + ) + expect(getPRCommentsMock).toHaveBeenCalledWith( + '/workspace/repo', + 42, + { noCache: true, prRepo }, + null, + localGitOptions + ) + expect(resolveReviewThreadMock).toHaveBeenCalledWith( + '/workspace/repo', + 'thread-1', + true, + null, + localGitOptions + ) + expect(setPRFileViewedMock).toHaveBeenCalledWith( + expect.objectContaining({ repoPath: '/workspace/repo', localGitOptions }) + ) + expect(addPRReviewCommentReplyMock).toHaveBeenCalledWith( + '/workspace/repo', + 42, + 11, + 'Reply', + 'thread-1', + 'src/app.ts', + 10, + null, + prRepo, + localGitOptions + ) + expect(addPRReviewCommentMock).toHaveBeenCalledWith( + expect.objectContaining({ + repoPath: '/workspace/repo', + commitId: 'head-sha', + body: 'Inline', + localGitOptions + }) + ) + expect(updatePRTitleMock).toHaveBeenCalledWith( + '/workspace/repo', + 42, + 'New title', + null, + prRepo, + localGitOptions + ) + expect(mergePRMock).toHaveBeenCalledWith( + '/workspace/repo', + 42, + 'squash', + null, + prRepo, + localGitOptions + ) + expect(setPRAutoMergeMock).toHaveBeenCalledWith( + '/workspace/repo', + 42, + true, + 'squash', + null, + prRepo, + localGitOptions + ) + expect(updatePRStateMock).toHaveBeenCalledWith( + '/workspace/repo', + 42, + { state: 'closed' }, + null, + localGitOptions + ) + expect(rerunPRChecksMock).toHaveBeenCalledWith( + '/workspace/repo', + 42, + { headSha: 'head-sha', failedOnly: true }, + null, + localGitOptions + ) + expect(requestPRReviewersMock).toHaveBeenCalledWith( + '/workspace/repo', + 42, + ['octo'], + null, + localGitOptions + ) + expect(removePRReviewersMock).toHaveBeenCalledWith( + '/workspace/repo', + 42, + ['octo'], + null, + localGitOptions + ) + }) + it('threads SSH connectionId through GitHub work-item handlers', async () => { repos[0].connectionId = 'openclaw-2' listWorkItemsMock.mockResolvedValue({ items: [] }) diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index 2a123ad1b5a..7136677fe57 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -61,6 +61,7 @@ import { import { getWorkItemDetails, getPRFileContents } from '../github/work-item-details' import { getRateLimit } from '../github/rate-limit' import { diagnoseGhAuth } from '../github/auth-diagnose' +import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' import type { GitHubPRFile } from '../../shared/types' import { dispatchWorkItem, type WorkItemArgs } from './github-work-item-args' import { @@ -165,6 +166,11 @@ function repoConnectionId(repo: Repo): string | null { return repo.connectionId ?? null } +function localGitOptionArgs(store: Store, repo: Repo): [] | [{ wslDistro?: string }] { + const localGitOptions = getLocalProjectWorktreeGitOptions(store, repo) + return Object.keys(localGitOptions).length > 0 ? [localGitOptions] : [] +} + export function registerGitHubHandlers(store: Store, stats: StatsCollector): void { function recordPRIfNeeded(repo: Repo, outcome: PRRefreshOutcome): void { if (outcome.kind === 'found' && !stats.hasCountedPR(outcome.pr.url)) { @@ -198,12 +204,16 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi } ) => { const repo = assertRegisteredRepo(args, store) + const localGitOptions = localGitOptionArgs(store, repo)[0] + const hostedReviewOptionArgs: [] | [{ localGitExecOptions: { wslDistro?: string } }] = + localGitOptions ? [{ localGitExecOptions: localGitOptions }] : [] const pr = await getPRForBranch( repo.path, args.branch, args.linkedPRNumber ?? null, repoConnectionId(repo), - args.linkedPRNumber == null ? (args.fallbackPRNumber ?? null) : null + args.linkedPRNumber == null ? (args.fallbackPRNumber ?? null) : null, + ...hostedReviewOptionArgs ) // Emit pr_created when a PR is first detected for a branch. // Why here: the renderer polls gh:prForBranch to check PR status per worktree. @@ -224,10 +234,12 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi 'gh:refreshPRNow', async (_event, args: { candidate: GitHubPRRefreshCandidate }) => { const repo = assertRegisteredRepo(args.candidate.repoPath, store) + const localGitOptions = localGitOptionArgs(store, repo)[0] const outcome = await refreshPRNow({ ...args.candidate, repoPath: repo.path, repoId: repo.id, + ...(localGitOptions ? { localGitOptions } : {}), connectionId: repo.connectionId ?? args.candidate.connectionId, connectionState: repo.connectionId ? 'connected' : args.candidate.connectionState }) @@ -247,11 +259,13 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi } ) => { const repo = assertRegisteredRepo(args.candidate.repoPath, store) + const localGitOptions = localGitOptionArgs(store, repo)[0] enqueuePRRefresh( { ...args.candidate, repoPath: repo.path, repoId: repo.id, + ...(localGitOptions ? { localGitOptions } : {}), connectionId: repo.connectionId ?? args.candidate.connectionId, connectionState: repo.connectionId ? 'connected' : args.candidate.connectionState }, @@ -275,10 +289,12 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi } const candidates = args.candidates.map((candidate) => { const repo = assertRegisteredRepo(candidate.repoPath, store) + const localGitOptions = localGitOptionArgs(store, repo)[0] return { ...candidate, repoPath: repo.path, repoId: repo.id, + ...(localGitOptions ? { localGitOptions } : {}), connectionId: repo.connectionId ?? candidate.connectionId, connectionState: repo.connectionId ? 'connected' : candidate.connectionState } @@ -300,7 +316,12 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi } ) => { const repo = assertRegisteredRepo(args, store) - return getIssue(repo.path, args.number, repoConnectionId(repo)) + return getIssue( + repo.path, + args.number, + repoConnectionId(repo), + ...localGitOptionArgs(store, repo) + ) } ) @@ -313,7 +334,8 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi repo.path, args.limit, repo.issueSourcePreference, - repoConnectionId(repo) + repoConnectionId(repo), + ...localGitOptionArgs(store, repo) ).then((r) => r.items) }) @@ -331,7 +353,8 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi args.body, repo.issueSourcePreference, repoConnectionId(repo), - fields + fields, + ...localGitOptionArgs(store, repo) ) } ) @@ -357,19 +380,27 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi args.before, repo.issueSourcePreference, repoConnectionId(repo), - args.noCache + args.noCache, + ...localGitOptionArgs(store, repo) ) } ) ipcMain.handle('gh:countWorkItems', (_event, args: { repoPath: string; query?: string }) => { const repo = assertRegisteredRepo(args, store) - return countWorkItems(repo.path, args.query, repo.issueSourcePreference, repoConnectionId(repo)) + return countWorkItems( + repo.path, + args.query, + repo.issueSourcePreference, + repoConnectionId(repo), + ...localGitOptionArgs(store, repo) + ) }) - ipcMain.handle('gh:workItem', (_event, args: WorkItemArgs) => - dispatchWorkItem(args, assertRegisteredRepo(args, store), getWorkItem) - ) + ipcMain.handle('gh:workItem', (_event, args: WorkItemArgs) => { + const repo = assertRegisteredRepo(args, store) + return dispatchWorkItem(args, repo, getWorkItem, localGitOptionArgs(store, repo)[0]) + }) ipcMain.handle( 'gh:workItemByOwnerRepo', ( @@ -388,13 +419,15 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi { owner: args.owner, repo: args.repo }, args.number, args.type, - repoConnectionId(repo) + repoConnectionId(repo), + ...localGitOptionArgs(store, repo) ) } ) - ipcMain.handle('gh:workItemDetails', (_event, args: WorkItemArgs) => - dispatchWorkItem(args, assertRegisteredRepo(args, store), getWorkItemDetails) - ) + ipcMain.handle('gh:workItemDetails', (_event, args: WorkItemArgs) => { + const repo = assertRegisteredRepo(args, store) + return dispatchWorkItem(args, repo, getWorkItemDetails, localGitOptionArgs(store, repo)[0]) + }) ipcMain.handle( 'gh:prFileContents', @@ -414,6 +447,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi return getPRFileContents({ repoPath: repo.path, connectionId: repoConnectionId(repo), + localGitOptions: localGitOptionArgs(store, repo)[0], prNumber: args.prNumber, path: args.path, oldPath: args.oldPath, @@ -426,12 +460,18 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi ipcMain.handle('gh:repoSlug', (_event, args: { repoPath: string }) => { const repo = assertRegisteredRepo(args, store) - return getRepoSlug(repo.path, repoConnectionId(repo)) + const localGitOptions = localGitOptionArgs(store, repo)[0] + return localGitOptions + ? getRepoSlug(repo.path, repoConnectionId(repo), { localGitExecOptions: localGitOptions }) + : getRepoSlug(repo.path, repoConnectionId(repo)) }) ipcMain.handle('gh:repoUpstream', (_event, args: { repoPath: string }) => { const repo = assertRegisteredRepo(args, store) - return getRepoUpstream(repo.path, repoConnectionId(repo)) + const localGitOptions = localGitOptionArgs(store, repo)[0] + return localGitOptions + ? getRepoUpstream(repo.path, repoConnectionId(repo), { localGitExecOptions: localGitOptions }) + : getRepoUpstream(repo.path, repoConnectionId(repo)) }) ipcMain.handle( @@ -457,7 +497,8 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi { noCache: args.noCache }, - repoConnectionId(repo) + repoConnectionId(repo), + ...localGitOptionArgs(store, repo) ) } ) @@ -487,7 +528,8 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi url: args.url, prRepo: args.prRepo ?? null }, - repoConnectionId(repo) + repoConnectionId(repo), + ...localGitOptionArgs(store, repo) ) } ) @@ -510,7 +552,8 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi repo.path, args.prNumber, { noCache: args.noCache, prRepo: args.prRepo ?? null }, - repoConnectionId(repo) + repoConnectionId(repo), + ...localGitOptionArgs(store, repo) ) } ) @@ -533,7 +576,13 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi // entries — emitting a path-wide invalidation here would require a new // event shape; instead, the drawer's existing thread-resolve UI updates // its local state immediately and the next reopen pays one fresh fetch. - return resolveReviewThread(repo.path, args.threadId, args.resolve, repoConnectionId(repo)) + return resolveReviewThread( + repo.path, + args.threadId, + args.resolve, + repoConnectionId(repo), + ...localGitOptionArgs(store, repo) + ) } ) @@ -564,6 +613,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi const ok = await setPRFileViewed({ repoPath: repo.path, connectionId: repoConnectionId(repo), + localGitOptions: localGitOptionArgs(store, repo)[0], pullRequestId: args.pullRequestId.trim(), path: args.path, viewed: Boolean(args.viewed) @@ -622,7 +672,8 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi args.path, args.line, repoConnectionId(repo), - args.prRepo ?? null + args.prRepo ?? null, + ...localGitOptionArgs(store, repo) ) if (result.ok) { broadcastWorkItemMutated( @@ -685,7 +736,8 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi line: args.line, startLine: args.startLine, body: args.body.trim(), - connectionId: repoConnectionId(repo) + connectionId: repoConnectionId(repo), + localGitOptions: localGitOptionArgs(store, repo)[0] }) if (result.ok) { broadcastWorkItemMutated( @@ -709,7 +761,8 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi args.prNumber, args.title, repoConnectionId(repo), - args.prRepo ?? null + args.prRepo ?? null, + ...localGitOptionArgs(store, repo) ) if (ok) { broadcastWorkItemMutated( @@ -740,7 +793,8 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi args.prNumber, args.method, repoConnectionId(repo), - args.prRepo ?? null + args.prRepo ?? null, + ...localGitOptionArgs(store, repo) ) if (result.ok) { broadcastWorkItemMutated( @@ -773,7 +827,8 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi args.enabled, args.method, repoConnectionId(repo), - args.prRepo ?? null + args.prRepo ?? null, + ...localGitOptionArgs(store, repo) ) if (result.ok) { broadcastWorkItemMutated( @@ -803,7 +858,8 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi repo.path, args.prNumber, args.updates, - repoConnectionId(repo) + repoConnectionId(repo), + ...localGitOptionArgs(store, repo) ) if (result.ok) { broadcastWorkItemMutated( @@ -833,7 +889,8 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi repo.path, args.prNumber, { headSha: args.headSha, failedOnly: args.failedOnly }, - repoConnectionId(repo) + repoConnectionId(repo), + ...localGitOptionArgs(store, repo) ) } ) @@ -846,7 +903,8 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi repo.path, args.prNumber, args.reviewers, - repoConnectionId(repo) + repoConnectionId(repo), + ...localGitOptionArgs(store, repo) ) if (result.ok) { broadcastWorkItemMutated( @@ -866,7 +924,8 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi repo.path, args.prNumber, args.reviewers, - repoConnectionId(repo) + repoConnectionId(repo), + ...localGitOptionArgs(store, repo) ) if (result.ok) { broadcastWorkItemMutated( @@ -888,7 +947,13 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi if (!args.updates || typeof args.updates !== 'object') { return { ok: false, error: 'Updates object is required' } } - const result = await updateIssue(repo.path, args.number, args.updates, repoConnectionId(repo)) + const result = await updateIssue( + repo.path, + args.number, + args.updates, + repoConnectionId(repo), + ...localGitOptionArgs(store, repo) + ) if (result.ok) { broadcastWorkItemMutated( { repoPath: repo.path, repoId: repo.id, type: 'issue', number: args.number }, @@ -925,7 +990,8 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi args.number, args.body.trim(), repoConnectionId(repo), - args.prRepo ?? null + args.prRepo ?? null, + ...localGitOptionArgs(store, repo) ) if (result.ok) { // Why: PR conversation comments hit `/issues/N/comments` too, but the @@ -944,12 +1010,22 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi ipcMain.handle('gh:listLabels', (_event, args: RepoScopedArgs) => { const repo = assertRegisteredRepo(args, store) - return listLabels(repo.path, repo.issueSourcePreference, repoConnectionId(repo)) + return listLabels( + repo.path, + repo.issueSourcePreference, + repoConnectionId(repo), + ...localGitOptionArgs(store, repo) + ) }) ipcMain.handle('gh:listAssignableUsers', (_event, args: RepoScopedArgs) => { const repo = assertRegisteredRepo(args, store) - return listAssignableUsers(repo.path, repo.issueSourcePreference, repoConnectionId(repo)) + return listAssignableUsers( + repo.path, + repo.issueSourcePreference, + repoConnectionId(repo), + ...localGitOptionArgs(store, repo) + ) }) // Star operations target the Orca repo itself — no repoPath validation needed diff --git a/src/main/ipc/gitlab.test.ts b/src/main/ipc/gitlab.test.ts index ace50f94068..6344fab3d68 100644 --- a/src/main/ipc/gitlab.test.ts +++ b/src/main/ipc/gitlab.test.ts @@ -1,12 +1,71 @@ -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Store } from '../persistence' import type { Repo } from '../../shared/types' import { toSshExecutionHostId } from '../../shared/execution-host' -const { ipcHandlers, listWorkItemsMock, getWorkItemByProjectRefMock } = vi.hoisted(() => ({ +const ORIGINAL_PLATFORM = process.platform + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { + configurable: true, + value: platform + }) +} + +const { + ipcHandlers, + listMergeRequestsMock, + getIssueMock, + listIssuesMock, + createIssueMock, + updateIssueMock, + addIssueCommentMock, + closeMRMock, + reopenMRMock, + mergeMRMock, + updateMRMock, + updateMRReviewersMock, + addMRCommentMock, + addMRInlineCommentMock, + resolveMRDiscussionMock, + getJobTraceMock, + retryJobMock, + listLabelsMock, + listAssignableUsersMock, + listTodosMock, + listWorkItemsMock, + getWorkItemDetailsMock, + getWorkItemByProjectRefMock, + getMergeRequestMock, + getMergeRequestForBranchMock, + getProjectSlugMock +} = vi.hoisted(() => ({ ipcHandlers: new Map unknown>(), + listMergeRequestsMock: vi.fn(), + getIssueMock: vi.fn(), + listIssuesMock: vi.fn(), + createIssueMock: vi.fn(), + updateIssueMock: vi.fn(), + addIssueCommentMock: vi.fn(), + closeMRMock: vi.fn(), + reopenMRMock: vi.fn(), + mergeMRMock: vi.fn(), + updateMRMock: vi.fn(), + updateMRReviewersMock: vi.fn(), + addMRCommentMock: vi.fn(), + addMRInlineCommentMock: vi.fn(), + resolveMRDiscussionMock: vi.fn(), + getJobTraceMock: vi.fn(), + retryJobMock: vi.fn(), + listLabelsMock: vi.fn(), + listAssignableUsersMock: vi.fn(), + listTodosMock: vi.fn(), listWorkItemsMock: vi.fn(), - getWorkItemByProjectRefMock: vi.fn() + getWorkItemDetailsMock: vi.fn(), + getWorkItemByProjectRefMock: vi.fn(), + getMergeRequestMock: vi.fn(), + getMergeRequestForBranchMock: vi.fn(), + getProjectSlugMock: vi.fn() })) vi.mock('electron', () => ({ @@ -18,37 +77,37 @@ vi.mock('electron', () => ({ })) vi.mock('../gitlab/client', () => ({ - addIssueComment: vi.fn(), - addMRInlineComment: vi.fn(), - addMRComment: vi.fn(), - closeMR: vi.fn(), - createIssue: vi.fn(), + addIssueComment: addIssueCommentMock, + addMRInlineComment: addMRInlineCommentMock, + addMRComment: addMRCommentMock, + closeMR: closeMRMock, + createIssue: createIssueMock, diagnoseAuth: vi.fn(), getAuthenticatedViewer: vi.fn(), - getJobTrace: vi.fn(), - getIssue: vi.fn(), - getMergeRequest: vi.fn(), - getMergeRequestForBranch: vi.fn(), - getProjectSlug: vi.fn(), + getJobTrace: getJobTraceMock, + getIssue: getIssueMock, + getMergeRequest: getMergeRequestMock, + getMergeRequestForBranch: getMergeRequestForBranchMock, + getProjectSlug: getProjectSlugMock, getRateLimit: vi.fn(), getWorkItemByProjectRef: getWorkItemByProjectRefMock, - listAssignableUsers: vi.fn(), - listIssues: vi.fn(), - listLabels: vi.fn(), - listMergeRequests: vi.fn(), - listTodos: vi.fn(), + listAssignableUsers: listAssignableUsersMock, + listIssues: listIssuesMock, + listLabels: listLabelsMock, + listMergeRequests: listMergeRequestsMock, + listTodos: listTodosMock, listWorkItems: listWorkItemsMock, - mergeMR: vi.fn(), - reopenMR: vi.fn(), - resolveMRDiscussion: vi.fn(), - retryJob: vi.fn(), - updateIssue: vi.fn(), - updateMR: vi.fn(), - updateMRReviewers: vi.fn() + mergeMR: mergeMRMock, + reopenMR: reopenMRMock, + resolveMRDiscussion: resolveMRDiscussionMock, + retryJob: retryJobMock, + updateIssue: updateIssueMock, + updateMR: updateMRMock, + updateMRReviewers: updateMRReviewersMock })) vi.mock('../gitlab/work-item-details', () => ({ - getWorkItemDetails: vi.fn() + getWorkItemDetails: getWorkItemDetailsMock })) vi.mock('../gitlab/gitlab-project-recents', () => ({ @@ -68,14 +127,56 @@ function repo(overrides: Partial = {}): Repo { } } -function storeWithRepos(repos: Repo[]): Pick { +function storeWithRepos( + repos: Repo[], + projects: ReturnType = [] +): Pick { return { getRepos: () => repos, - getRepo: (id: string) => repos.find((candidate) => candidate.id === id) + getRepo: (id: string) => repos.find((candidate) => candidate.id === id), + getProjects: () => projects, + getSettings: () => + ({ + localWindowsRuntimeDefault: { kind: 'windows-host' } + }) as ReturnType } } describe('GitLab IPC handlers', () => { + beforeEach(() => { + setPlatform(ORIGINAL_PLATFORM) + ipcHandlers.clear() + for (const mock of [ + listMergeRequestsMock, + getIssueMock, + listIssuesMock, + createIssueMock, + updateIssueMock, + addIssueCommentMock, + closeMRMock, + reopenMRMock, + mergeMRMock, + updateMRMock, + updateMRReviewersMock, + addMRCommentMock, + addMRInlineCommentMock, + resolveMRDiscussionMock, + getJobTraceMock, + retryJobMock, + listLabelsMock, + listAssignableUsersMock, + listTodosMock, + listWorkItemsMock, + getWorkItemDetailsMock, + getWorkItemByProjectRefMock, + getMergeRequestMock, + getMergeRequestForBranchMock, + getProjectSlugMock + ]) { + mock.mockReset() + } + }) + it('resolves repoId and source host context before listing work items', async () => { const remoteRepo = repo({ id: 'repo-ssh', @@ -174,4 +275,342 @@ describe('GitLab IPC handlers', () => { 'builder' ) }) + + it('routes local WSL project GitLab issue, MR, work-item, and todo IPC through project git options', async () => { + setPlatform('win32') + const projects: ReturnType = [ + { + id: 'project-1', + displayName: 'Orca', + badgeColor: 'blue', + sourceRepoIds: ['repo-local'], + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }, + createdAt: 0, + updatedAt: 0 + } + ] + listMergeRequestsMock.mockResolvedValue({ items: [] }) + listWorkItemsMock.mockResolvedValue({ items: [] }) + listIssuesMock.mockResolvedValue({ items: [] }) + getIssueMock.mockResolvedValue(null) + createIssueMock.mockResolvedValue({ ok: true, number: 1, url: 'https://gitlab.example/1' }) + updateIssueMock.mockResolvedValue({ ok: true }) + addIssueCommentMock.mockResolvedValue({ ok: true }) + listLabelsMock.mockResolvedValue([]) + listAssignableUsersMock.mockResolvedValue([]) + listTodosMock.mockResolvedValue([]) + getProjectSlugMock.mockResolvedValue({ host: 'gitlab.com', path: 'stablyai/orca' }) + getMergeRequestForBranchMock.mockResolvedValue(null) + getMergeRequestMock.mockResolvedValue(null) + registerGitLabHandlers(storeWithRepos([repo()], projects) as Store) + const localGitOptions = { wslDistro: 'Ubuntu' } + + await ipcHandlers.get('gitlab:projectSlug')?.(null, { repoPath: '/local/orca' }) + await ipcHandlers.get('gitlab:mrForBranch')?.(null, { + repoPath: '/local/orca', + branch: 'feature/wsl' + }) + await ipcHandlers.get('gitlab:mr')?.(null, { repoPath: '/local/orca', iid: 8 }) + await ipcHandlers.get('gitlab:listMRs')?.(null, { + repoPath: '/local/orca', + state: 'opened', + page: 1, + perPage: 20 + }) + await ipcHandlers.get('gitlab:listWorkItems')?.(null, { + repoPath: '/local/orca', + state: 'opened', + page: 1, + perPage: 20 + }) + await ipcHandlers.get('gitlab:listIssues')?.(null, { + repoPath: '/local/orca', + state: 'opened', + limit: 20 + }) + await ipcHandlers.get('gitlab:issue')?.(null, { repoPath: '/local/orca', number: 7 }) + await ipcHandlers.get('gitlab:createIssue')?.(null, { + repoPath: '/local/orca', + title: 'Title', + body: 'Body' + }) + await ipcHandlers.get('gitlab:updateIssue')?.(null, { + repoPath: '/local/orca', + number: 7, + updates: { body: 'Updated' } + }) + await ipcHandlers.get('gitlab:addIssueComment')?.(null, { + repoPath: '/local/orca', + number: 7, + body: 'Comment' + }) + await ipcHandlers.get('gitlab:listLabels')?.(null, { repoPath: '/local/orca' }) + await ipcHandlers.get('gitlab:listAssignableUsers')?.(null, { repoPath: '/local/orca' }) + await ipcHandlers.get('gitlab:todos')?.(null, { repoPath: '/local/orca' }) + + const hostedReviewOptions = { localGitExecOptions: localGitOptions } + expect(getProjectSlugMock).toHaveBeenCalledWith('/local/orca', null, hostedReviewOptions) + expect(getMergeRequestForBranchMock).toHaveBeenCalledWith( + '/local/orca', + 'feature/wsl', + null, + null, + hostedReviewOptions + ) + expect(getMergeRequestMock).toHaveBeenCalledWith('/local/orca', 8, null, hostedReviewOptions) + expect(listMergeRequestsMock).toHaveBeenCalledWith( + '/local/orca', + 'opened', + 1, + 20, + undefined, + undefined, + null, + localGitOptions + ) + expect(listWorkItemsMock).toHaveBeenCalledWith( + '/local/orca', + 'opened', + 1, + 20, + undefined, + undefined, + null, + localGitOptions + ) + expect(listIssuesMock).toHaveBeenCalledWith( + '/local/orca', + 20, + undefined, + 'opened', + undefined, + null, + localGitOptions + ) + expect(getIssueMock).toHaveBeenCalledWith('/local/orca', 7, null, localGitOptions) + expect(createIssueMock).toHaveBeenCalledWith( + '/local/orca', + 'Title', + 'Body', + undefined, + null, + localGitOptions + ) + expect(updateIssueMock).toHaveBeenCalledWith( + '/local/orca', + 7, + { body: 'Updated' }, + undefined, + null, + undefined, + localGitOptions + ) + expect(addIssueCommentMock).toHaveBeenCalledWith( + '/local/orca', + 7, + 'Comment', + undefined, + null, + undefined, + localGitOptions + ) + expect(listLabelsMock).toHaveBeenCalledWith('/local/orca', undefined, null, localGitOptions) + expect(listAssignableUsersMock).toHaveBeenCalledWith( + '/local/orca', + undefined, + null, + localGitOptions + ) + expect(listTodosMock).toHaveBeenCalledWith('/local/orca', null, localGitOptions) + }) + + it('routes local WSL project GitLab MR details, review, job, and pasted URL IPC through project git options', async () => { + setPlatform('win32') + const projects: ReturnType = [ + { + id: 'project-1', + displayName: 'Orca', + badgeColor: 'blue', + sourceRepoIds: ['repo-local'], + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }, + createdAt: 0, + updatedAt: 0 + } + ] + const inlineInput = { + body: 'Inline', + path: 'src/app.ts', + line: 12, + baseSha: 'base', + startSha: 'start', + headSha: 'head' + } + getWorkItemDetailsMock.mockResolvedValue({ body: 'Details' }) + closeMRMock.mockResolvedValue({ ok: true }) + reopenMRMock.mockResolvedValue({ ok: true }) + mergeMRMock.mockResolvedValue({ ok: true }) + updateMRMock.mockResolvedValue({ ok: true }) + updateMRReviewersMock.mockResolvedValue({ ok: true, reviewers: [] }) + addMRCommentMock.mockResolvedValue({ ok: true }) + addMRInlineCommentMock.mockResolvedValue({ ok: true }) + resolveMRDiscussionMock.mockResolvedValue({ ok: true }) + getJobTraceMock.mockResolvedValue({ ok: true, trace: 'trace' }) + retryJobMock.mockResolvedValue({ ok: true }) + getWorkItemByProjectRefMock.mockResolvedValue({ type: 'mr', number: 8 }) + registerGitLabHandlers(storeWithRepos([repo()], projects) as Store) + const localGitOptions = { wslDistro: 'Ubuntu' } + + await ipcHandlers.get('gitlab:workItemDetails')?.(null, { + repoPath: '/local/orca', + iid: 8, + type: 'mr' + }) + await ipcHandlers.get('gitlab:closeMR')?.(null, { repoPath: '/local/orca', iid: 8 }) + await ipcHandlers.get('gitlab:reopenMR')?.(null, { repoPath: '/local/orca', iid: 8 }) + await ipcHandlers.get('gitlab:mergeMR')?.(null, { + repoPath: '/local/orca', + iid: 8, + method: 'squash' + }) + await ipcHandlers.get('gitlab:updateMR')?.(null, { + repoPath: '/local/orca', + iid: 8, + updates: { title: 'Renamed' } + }) + await ipcHandlers.get('gitlab:updateMRReviewers')?.(null, { + repoPath: '/local/orca', + iid: 8, + reviewerIds: [1] + }) + await ipcHandlers.get('gitlab:addMRComment')?.(null, { + repoPath: '/local/orca', + iid: 8, + body: 'Comment' + }) + await ipcHandlers.get('gitlab:addMRInlineComment')?.(null, { + repoPath: '/local/orca', + iid: 8, + input: inlineInput + }) + await ipcHandlers.get('gitlab:resolveMRDiscussion')?.(null, { + repoPath: '/local/orca', + iid: 8, + discussionId: 'discussion-1', + resolved: true + }) + await ipcHandlers.get('gitlab:jobTrace')?.(null, { repoPath: '/local/orca', jobId: 99 }) + await ipcHandlers.get('gitlab:retryJob')?.(null, { repoPath: '/local/orca', jobId: 99 }) + await ipcHandlers.get('gitlab:workItemByPath')?.(null, { + repoPath: '/local/orca', + host: 'gitlab.com', + path: 'g/p', + iid: 8, + type: 'mr' + }) + + expect(getWorkItemDetailsMock).toHaveBeenCalledWith( + '/local/orca', + 8, + 'mr', + undefined, + null, + undefined, + localGitOptions + ) + expect(closeMRMock).toHaveBeenCalledWith( + '/local/orca', + 8, + undefined, + null, + undefined, + localGitOptions + ) + expect(reopenMRMock).toHaveBeenCalledWith( + '/local/orca', + 8, + undefined, + null, + undefined, + localGitOptions + ) + expect(mergeMRMock).toHaveBeenCalledWith( + '/local/orca', + 8, + 'squash', + undefined, + null, + undefined, + localGitOptions + ) + expect(updateMRMock).toHaveBeenCalledWith( + '/local/orca', + 8, + { title: 'Renamed' }, + undefined, + null, + undefined, + localGitOptions + ) + expect(updateMRReviewersMock).toHaveBeenCalledWith( + '/local/orca', + 8, + [1], + undefined, + null, + undefined, + localGitOptions + ) + expect(addMRCommentMock).toHaveBeenCalledWith( + '/local/orca', + 8, + 'Comment', + undefined, + null, + undefined, + localGitOptions + ) + expect(addMRInlineCommentMock).toHaveBeenCalledWith( + '/local/orca', + 8, + inlineInput, + undefined, + null, + undefined, + localGitOptions + ) + expect(resolveMRDiscussionMock).toHaveBeenCalledWith( + '/local/orca', + 8, + 'discussion-1', + true, + undefined, + null, + undefined, + localGitOptions + ) + expect(getJobTraceMock).toHaveBeenCalledWith( + '/local/orca', + 99, + undefined, + null, + undefined, + localGitOptions + ) + expect(retryJobMock).toHaveBeenCalledWith( + '/local/orca', + 99, + undefined, + null, + undefined, + localGitOptions + ) + expect(getWorkItemByProjectRefMock).toHaveBeenCalledWith( + '/local/orca', + { host: 'gitlab.com', path: 'g/p' }, + 8, + 'mr', + null, + localGitOptions + ) + }) }) diff --git a/src/main/ipc/gitlab.ts b/src/main/ipc/gitlab.ts index 04fe0354ee8..241060e0afb 100644 --- a/src/main/ipc/gitlab.ts +++ b/src/main/ipc/gitlab.ts @@ -51,6 +51,9 @@ import { } from '../gitlab/client' import { getWorkItemDetails } from '../gitlab/work-item-details' import type { ProjectRef } from '../gitlab/gl-utils' +import type { LocalGitExecOptions } from '../gitlab/gitlab-project-ref-resolution' +import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' +import type { HostedReviewExecutionOptions } from '../source-control/hosted-review-git-options' type GitLabRepoSelectorArgs = { repoPath: string @@ -94,6 +97,18 @@ function repoConnectionId(repo: Repo): string | null { return repo.connectionId ?? null } +function localGitOptionArgs(store: Store, repo: Repo): [] | [LocalGitExecOptions] { + const localGitOptions = getLocalProjectWorktreeGitOptions(store, repo) + return localGitOptions.wslDistro ? [{ wslDistro: localGitOptions.wslDistro }] : [] +} + +function hostedReviewOptionArgs(store: Store, repo: Repo): [] | [HostedReviewExecutionOptions] { + const localGitOptions = getLocalProjectWorktreeGitOptions(store, repo) + return localGitOptions.wslDistro + ? [{ localGitExecOptions: { wslDistro: localGitOptions.wslDistro } }] + : [] +} + export function registerGitLabHandlers(store: Store): void { ipcMain.handle('gitlab:viewer', async () => { return getAuthenticatedViewer() @@ -109,7 +124,7 @@ export function registerGitLabHandlers(store: Store): void { ipcMain.handle('gitlab:projectSlug', async (_event, args: GitLabRepoSelectorArgs) => { const repo = assertRegisteredRepo(args, store) - return getProjectSlug(repo.path, repoConnectionId(repo)) + return getProjectSlug(repo.path, repoConnectionId(repo), ...hostedReviewOptionArgs(store, repo)) }) ipcMain.handle( @@ -123,14 +138,20 @@ export function registerGitLabHandlers(store: Store): void { repo.path, args.branch, args.linkedMRIid ?? null, - repoConnectionId(repo) + repoConnectionId(repo), + ...hostedReviewOptionArgs(store, repo) ) } ) ipcMain.handle('gitlab:mr', async (_event, args: GitLabRepoSelectorArgs & { iid: number }) => { const repo = assertRegisteredRepo(args, store) - return getMergeRequest(repo.path, args.iid, repoConnectionId(repo)) + return getMergeRequest( + repo.path, + args.iid, + repoConnectionId(repo), + ...hostedReviewOptionArgs(store, repo) + ) }) ipcMain.handle( @@ -157,7 +178,8 @@ export function registerGitLabHandlers(store: Store): void { perPage, repo.issueSourcePreference, undefined, - repoConnectionId(repo) + repoConnectionId(repo), + ...localGitOptionArgs(store, repo) ) } ) @@ -166,7 +188,12 @@ export function registerGitLabHandlers(store: Store): void { 'gitlab:issue', async (_event, args: GitLabRepoSelectorArgs & { number: number }) => { const repo = assertRegisteredRepo(args, store) - return getIssue(repo.path, args.number, repoConnectionId(repo)) + return getIssue( + repo.path, + args.number, + repoConnectionId(repo), + ...localGitOptionArgs(store, repo) + ) } ) @@ -193,7 +220,8 @@ export function registerGitLabHandlers(store: Store): void { repo.issueSourcePreference, state, assignee, - repoConnectionId(repo) + repoConnectionId(repo), + ...localGitOptionArgs(store, repo) ) // Why: Tasks page expects GitLabWorkItem[] so it can share row // rendering with MRs. Map IssueInfo → WorkItem here so the renderer @@ -223,7 +251,8 @@ export function registerGitLabHandlers(store: Store): void { args.title, args.body, repo.issueSourcePreference, - repoConnectionId(repo) + repoConnectionId(repo), + ...localGitOptionArgs(store, repo) ) } ) @@ -240,7 +269,9 @@ export function registerGitLabHandlers(store: Store): void { args.number, args.updates, repo.issueSourcePreference, - repoConnectionId(repo) + repoConnectionId(repo), + undefined, + ...localGitOptionArgs(store, repo) ) } ) @@ -254,19 +285,31 @@ export function registerGitLabHandlers(store: Store): void { args.number, args.body, repo.issueSourcePreference, - repoConnectionId(repo) + repoConnectionId(repo), + undefined, + ...localGitOptionArgs(store, repo) ) } ) ipcMain.handle('gitlab:listLabels', async (_event, args: GitLabRepoSelectorArgs) => { const repo = assertRegisteredRepo(args, store) - return listLabels(repo.path, repo.issueSourcePreference, repoConnectionId(repo)) + return listLabels( + repo.path, + repo.issueSourcePreference, + repoConnectionId(repo), + ...localGitOptionArgs(store, repo) + ) }) ipcMain.handle('gitlab:listAssignableUsers', async (_event, args: GitLabRepoSelectorArgs) => { const repo = assertRegisteredRepo(args, store) - return listAssignableUsers(repo.path, repo.issueSourcePreference, repoConnectionId(repo)) + return listAssignableUsers( + repo.path, + repo.issueSourcePreference, + repoConnectionId(repo), + ...localGitOptionArgs(store, repo) + ) }) // Why: combined MR + issue list — Tasks screen and any future picker @@ -293,7 +336,8 @@ export function registerGitLabHandlers(store: Store): void { normalizeGitLabPositiveInteger(args.perPage, 20, 100), repo.issueSourcePreference, undefined, - repoConnectionId(repo) + repoConnectionId(repo), + ...localGitOptionArgs(store, repo) ) } ) @@ -309,7 +353,9 @@ export function registerGitLabHandlers(store: Store): void { args.iid, args.type, repo.issueSourcePreference, - repoConnectionId(repo) + repoConnectionId(repo), + undefined, + ...localGitOptionArgs(store, repo) ) } ) @@ -318,7 +364,14 @@ export function registerGitLabHandlers(store: Store): void { 'gitlab:closeMR', async (_event, args: GitLabRepoSelectorArgs & { iid: number }) => { const repo = assertRegisteredRepo(args, store) - return closeMR(repo.path, args.iid, repo.issueSourcePreference, repoConnectionId(repo)) + return closeMR( + repo.path, + args.iid, + repo.issueSourcePreference, + repoConnectionId(repo), + undefined, + ...localGitOptionArgs(store, repo) + ) } ) @@ -326,7 +379,14 @@ export function registerGitLabHandlers(store: Store): void { 'gitlab:reopenMR', async (_event, args: GitLabRepoSelectorArgs & { iid: number }) => { const repo = assertRegisteredRepo(args, store) - return reopenMR(repo.path, args.iid, repo.issueSourcePreference, repoConnectionId(repo)) + return reopenMR( + repo.path, + args.iid, + repo.issueSourcePreference, + repoConnectionId(repo), + undefined, + ...localGitOptionArgs(store, repo) + ) } ) @@ -342,7 +402,9 @@ export function registerGitLabHandlers(store: Store): void { args.iid, args.method ?? 'merge', repo.issueSourcePreference, - repoConnectionId(repo) + repoConnectionId(repo), + undefined, + ...localGitOptionArgs(store, repo) ) } ) @@ -356,7 +418,9 @@ export function registerGitLabHandlers(store: Store): void { args.iid, args.updates, repo.issueSourcePreference, - repoConnectionId(repo) + repoConnectionId(repo), + undefined, + ...localGitOptionArgs(store, repo) ) } ) @@ -381,7 +445,8 @@ export function registerGitLabHandlers(store: Store): void { args.reviewerIds, repo.issueSourcePreference, repoConnectionId(repo), - args.projectRef + args.projectRef, + ...localGitOptionArgs(store, repo) ) } ) @@ -395,7 +460,9 @@ export function registerGitLabHandlers(store: Store): void { args.iid, args.body, repo.issueSourcePreference, - repoConnectionId(repo) + repoConnectionId(repo), + undefined, + ...localGitOptionArgs(store, repo) ) } ) @@ -420,7 +487,8 @@ export function registerGitLabHandlers(store: Store): void { args.input, repo.issueSourcePreference, repoConnectionId(repo), - args.projectRef + args.projectRef, + ...localGitOptionArgs(store, repo) ) } ) @@ -438,7 +506,9 @@ export function registerGitLabHandlers(store: Store): void { args.discussionId, args.resolved, repo.issueSourcePreference, - repoConnectionId(repo) + repoConnectionId(repo), + undefined, + ...localGitOptionArgs(store, repo) ) } ) @@ -455,7 +525,8 @@ export function registerGitLabHandlers(store: Store): void { args.jobId, repo.issueSourcePreference, repoConnectionId(repo), - args.projectRef + args.projectRef, + ...localGitOptionArgs(store, repo) ) } ) @@ -472,7 +543,8 @@ export function registerGitLabHandlers(store: Store): void { args.jobId, repo.issueSourcePreference, repoConnectionId(repo), - args.projectRef + args.projectRef, + ...localGitOptionArgs(store, repo) ) } ) @@ -482,7 +554,7 @@ export function registerGitLabHandlers(store: Store): void { // care about cwd because the endpoint is user-scoped. ipcMain.handle('gitlab:todos', async (_event, args: GitLabRepoSelectorArgs) => { const repo = assertRegisteredRepo(args, store) - return listTodos(repo.path, repoConnectionId(repo)) + return listTodos(repo.path, repoConnectionId(repo), ...localGitOptionArgs(store, repo)) }) // Why: paste-URL flow in the picker. The user pastes a GitLab URL that @@ -507,7 +579,8 @@ export function registerGitLabHandlers(store: Store): void { projectRef, args.iid, args.type, - repoConnectionId(repo) + repoConnectionId(repo), + ...localGitOptionArgs(store, repo) ) // Why: only persist a recent entry when the lookup actually // produced an item. A 404 / auth failure shouldn't pollute the diff --git a/src/main/ipc/hosted-review.test.ts b/src/main/ipc/hosted-review.test.ts index 88ddd56502d..fea4098b8b7 100644 --- a/src/main/ipc/hosted-review.test.ts +++ b/src/main/ipc/hosted-review.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { resolve } from 'path' const { handleMock, @@ -47,7 +48,14 @@ describe('registerHostedReviewHandlers', () => { const handlers: HandlerMap = {} const repoPath = '/remote/workspace/repo' const worktreePath = '/remote/workspace/feature-worktree' - const repo = { + const repo: { + id: string + path: string + displayName: string + badgeColor: string + addedAt: number + connectionId?: string + } = { id: 'repo-1', path: repoPath, displayName: 'repo', @@ -57,7 +65,9 @@ describe('registerHostedReviewHandlers', () => { } const store = { getRepo: vi.fn((repoId: string) => (repoId === repo.id ? repo : null)), - getRepos: vi.fn(() => [repo]) + getRepos: vi.fn(() => [repo]), + getProjects: vi.fn((): Record[] => []), + getSettings: vi.fn(() => ({ localWindowsRuntimeDefault: { kind: 'windows-host' } })) } const stats = { hasCountedPR: vi.fn(() => false), @@ -71,8 +81,10 @@ describe('registerHostedReviewHandlers', () => { getHostedReviewForBranchMock.mockReset() resolveRegisteredWorktreePathMock.mockReset() listRepoWorktreesMock.mockReset() - store.getRepo.mockClear() - store.getRepos.mockClear() + store.getRepo.mockReset() + store.getRepos.mockReset() + store.getProjects.mockReset() + store.getSettings.mockReset() stats.hasCountedPR.mockClear() stats.record.mockClear() for (const key of Object.keys(handlers)) { @@ -81,9 +93,124 @@ describe('registerHostedReviewHandlers', () => { handleMock.mockImplementation((channel, handler) => { handlers[channel] = handler }) + store.getRepo.mockImplementation((repoId: string) => (repoId === repo.id ? repo : null)) + store.getRepos.mockReturnValue([repo]) + store.getProjects.mockReturnValue([]) + store.getSettings.mockReturnValue({ localWindowsRuntimeDefault: { kind: 'windows-host' } }) listRepoWorktreesMock.mockResolvedValue([{ path: worktreePath }]) }) + it('routes local WSL project review creation through main-process runtime options', async () => { + const localRepo = { + id: 'repo-local', + path: '/workspace/repo', + displayName: 'local', + badgeColor: '#000', + addedAt: 0 + } + store.getRepo.mockImplementation((repoId: string) => + repoId === localRepo.id ? localRepo : null + ) + store.getRepos.mockReturnValue([localRepo]) + store.getProjects.mockReturnValue([ + { + id: 'project-1', + displayName: 'local', + badgeColor: '#000', + sourceRepoIds: [localRepo.id], + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }, + createdAt: 0, + updatedAt: 0 + } + ]) + const resolvedWorktreePath = resolve('/workspace/feature') + resolveRegisteredWorktreePathMock.mockResolvedValue(resolvedWorktreePath) + listRepoWorktreesMock.mockResolvedValue([{ path: resolvedWorktreePath }]) + createHostedReviewMock.mockResolvedValueOnce({ + ok: true, + number: 42, + url: 'https://github.com/acme/orca/pull/42' + }) + + registerHostedReviewHandlers(store as never, stats as never) + + await handlers['hostedReview:create'](null, { + repoPath: localRepo.path, + repoId: localRepo.id, + worktreePath: '/workspace/feature', + provider: 'github', + base: 'main', + head: 'feature/pr', + title: 'Feature PR' + }) + + expect(listRepoWorktreesMock).toHaveBeenCalledWith(localRepo, { wslDistro: 'Ubuntu' }) + expect(createHostedReviewMock).toHaveBeenCalledWith( + resolvedWorktreePath, + expect.objectContaining({ + provider: 'github', + head: 'feature/pr', + title: 'Feature PR' + }), + null, + { localGitExecOptions: { wslDistro: 'Ubuntu' } } + ) + }) + + it('routes local WSL project review status through main-process runtime options', async () => { + const localRepo = { + id: 'repo-local', + path: '/workspace/repo', + displayName: 'local', + badgeColor: '#000', + addedAt: 0 + } + store.getRepo.mockImplementation((repoId: string) => + repoId === localRepo.id ? localRepo : null + ) + store.getRepos.mockReturnValue([localRepo]) + store.getProjects.mockReturnValue([ + { + id: 'project-1', + displayName: 'local', + badgeColor: '#000', + sourceRepoIds: [localRepo.id], + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }, + createdAt: 0, + updatedAt: 0 + } + ]) + getHostedReviewForBranchMock.mockResolvedValueOnce({ + provider: 'github', + number: 42, + title: 'Feature PR', + state: 'open', + url: 'https://github.com/acme/orca/pull/42', + status: 'success', + updatedAt: '2026-06-16T00:00:00.000Z', + mergeable: 'MERGEABLE' + }) + + registerHostedReviewHandlers(store as never, stats as never) + + await handlers['hostedReview:forBranch'](null, { + repoPath: localRepo.path, + repoId: localRepo.id, + branch: 'feature/wsl', + linkedGitHubPR: 42 + }) + + expect(getHostedReviewForBranchMock).toHaveBeenCalledWith( + expect.objectContaining({ + repoPath: localRepo.path, + connectionId: undefined, + branch: 'feature/wsl', + linkedGitHubPR: 42, + localGitExecOptions: { wslDistro: 'Ubuntu' } + }) + ) + }) + it('passes SSH connectionId through create eligibility instead of blocking the worktree', async () => { getHostedReviewCreationEligibilityMock.mockResolvedValueOnce({ provider: 'github', diff --git a/src/main/ipc/hosted-review.ts b/src/main/ipc/hosted-review.ts index 69ea3084f39..53879a8d400 100644 --- a/src/main/ipc/hosted-review.ts +++ b/src/main/ipc/hosted-review.ts @@ -15,6 +15,7 @@ import { import { getHostedReviewForBranch } from '../source-control/hosted-review' import { resolveRegisteredWorktreePath } from './filesystem-auth' import { listRepoWorktrees } from '../repo-worktrees' +import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' function assertRegisteredRepo(repoPath: string, store: Store, repoId?: string): Repo { if (repoId) { @@ -53,7 +54,11 @@ async function resolveHostedReviewWorktreePath( return remoteWorktreePath } const resolvedWorktreePath = await resolveRegisteredWorktreePath(worktreePath, store) - const repoWorktrees = await listRepoWorktrees(repo) + const localGitOptions = getLocalProjectWorktreeGitOptions(store, repo) + const repoWorktrees = + Object.keys(localGitOptions).length > 0 + ? await listRepoWorktrees(repo, localGitOptions) + : await listRepoWorktrees(repo) if (!repoWorktrees.some((worktree) => resolve(worktree.path) === resolvedWorktreePath)) { throw new Error('Access denied: worktree does not belong to repository') } @@ -73,6 +78,7 @@ function normalizeRemoteHostedReviewPath(remotePath: string): string { export function registerHostedReviewHandlers(store: Store, stats: StatsCollector): void { ipcMain.handle('hostedReview:forBranch', async (_event, args: HostedReviewForBranchArgs) => { const repo = assertRegisteredRepo(args.repoPath, store, args.repoId) + const localGitOptions = getLocalProjectWorktreeGitOptions(store, repo) const review = await getHostedReviewForBranch({ repoPath: repo.path, connectionId: repo.connectionId, @@ -82,7 +88,8 @@ export function registerHostedReviewHandlers(store: Store, stats: StatsCollector linkedGitLabMR: args.linkedGitLabMR ?? null, linkedBitbucketPR: args.linkedBitbucketPR ?? null, linkedAzureDevOpsPR: args.linkedAzureDevOpsPR ?? null, - linkedGiteaPR: args.linkedGiteaPR ?? null + linkedGiteaPR: args.linkedGiteaPR ?? null, + ...(Object.keys(localGitOptions).length > 0 ? { localGitExecOptions: localGitOptions } : {}) }) if (review?.provider === 'github' && !stats.hasCountedPR(review.url)) { stats.record({ @@ -100,10 +107,12 @@ export function registerHostedReviewHandlers(store: Store, stats: StatsCollector async (_event, args: HostedReviewCreationEligibilityArgs) => { const repo = assertRegisteredRepo(args.repoPath, store, args.repoId) const worktreePath = await resolveHostedReviewWorktreePath(repo, store, args.worktreePath) + const localGitOptions = getLocalProjectWorktreeGitOptions(store, repo) return getHostedReviewCreationEligibility({ ...args, repoPath: worktreePath, - connectionId: repo.connectionId ?? null + connectionId: repo.connectionId ?? null, + ...(Object.keys(localGitOptions).length > 0 ? { localGitExecOptions: localGitOptions } : {}) }) } ) @@ -111,19 +120,21 @@ export function registerHostedReviewHandlers(store: Store, stats: StatsCollector ipcMain.handle('hostedReview:create', async (_event, args: CreateHostedReviewArgs) => { const repo = assertRegisteredRepo(args.repoPath, store, args.repoId) const worktreePath = await resolveHostedReviewWorktreePath(repo, store, args.worktreePath) - const result = await createHostedReview( - worktreePath, - { - provider: args.provider, - base: args.base, - head: args.head, - title: args.title, - body: args.body, - draft: args.draft, - useTemplate: args.useTemplate - }, - repo.connectionId ?? null - ) + const localGitOptions = getLocalProjectWorktreeGitOptions(store, repo) + const executionOptions = + Object.keys(localGitOptions).length > 0 ? { localGitExecOptions: localGitOptions } : undefined + const input = { + provider: args.provider, + base: args.base, + head: args.head, + title: args.title, + body: args.body, + draft: args.draft, + ...(args.useTemplate !== undefined ? { useTemplate: args.useTemplate } : {}) + } + const result = executionOptions + ? await createHostedReview(worktreePath, input, repo.connectionId ?? null, executionOptions) + : await createHostedReview(worktreePath, input, repo.connectionId ?? null) if (result.ok && !stats.hasCountedPR(result.url)) { stats.record({ type: 'pr_created', diff --git a/src/main/ipc/local-worktree-runtime-options.ts b/src/main/ipc/local-worktree-runtime-options.ts new file mode 100644 index 00000000000..40cbf657240 --- /dev/null +++ b/src/main/ipc/local-worktree-runtime-options.ts @@ -0,0 +1,61 @@ +import { resolve } from 'path' +import type { Store } from '../persistence' +import { + getLocalProjectWorktreeGitOptions, + type LocalProjectWorktreeGitOptions +} from '../project-runtime-git-options' +import { splitWorktreeId } from '../../shared/worktree-id' + +function comparableLocalPath(value: string): string { + const normalized = resolve(value) + return process.platform === 'win32' ? normalized.toLowerCase() : normalized +} + +function getCandidateLocalWorktreePaths( + worktreePath: string, + resolvedWorktreePath: string +): Set { + return new Set([worktreePath, resolvedWorktreePath].map(comparableLocalPath)) +} + +function hasRegisteredWorktreeMetaForRepo( + store: Store, + repoId: string, + candidatePaths: Set +): boolean { + const worktreeMeta = + typeof store.getAllWorktreeMeta === 'function' ? store.getAllWorktreeMeta() : {} + for (const worktreeId of Object.keys(worktreeMeta)) { + const parsed = splitWorktreeId(worktreeId) + if (parsed?.repoId === repoId && candidatePaths.has(comparableLocalPath(parsed.worktreePath))) { + return true + } + } + return false +} + +export function getLocalGitOptionsForRegisteredWorktree( + store: Store, + worktreePath: string, + resolvedWorktreePath: string +): LocalProjectWorktreeGitOptions { + if (typeof store.getProjects !== 'function' || typeof store.getSettings !== 'function') { + return {} + } + + const candidatePaths = getCandidateLocalWorktreePaths(worktreePath, resolvedWorktreePath) + for (const repo of store.getRepos()) { + if (repo.connectionId) { + continue + } + if ( + candidatePaths.has(comparableLocalPath(repo.path)) || + hasRegisteredWorktreeMetaForRepo(store, repo.id, candidatePaths) + ) { + // Why: file discovery must use the same resolved runtime as project git, + // terminals, and agents even when the worktree path is a Windows path. + return getLocalProjectWorktreeGitOptions(store, repo) + } + } + return {} +} diff --git a/src/main/ipc/notebook.test.ts b/src/main/ipc/notebook.test.ts index 3e4304239a6..c0b4875d367 100644 --- a/src/main/ipc/notebook.test.ts +++ b/src/main/ipc/notebook.test.ts @@ -33,7 +33,8 @@ function createMockProcess(pid = 1234): ChildProcessWithoutNullStreams { pid, stdout: new EventEmitter(), stderr: new EventEmitter(), - kill: vi.fn() + kill: vi.fn(), + unref: vi.fn() }) return proc } @@ -56,7 +57,8 @@ describe('notebook IPC', () => { it('kills the Python process group when a cell times out', async () => { const proc = createMockProcess(4321) - spawnMock.mockReturnValue(proc) + const killerProc = createMockProcess(4322) + spawnMock.mockReturnValueOnce(proc).mockReturnValue(killerProc) registerNotebookHandlers({} as never) const handler = handlers.get('notebook:runPythonCell') diff --git a/src/main/ipc/preflight-runtime-target.ts b/src/main/ipc/preflight-runtime-target.ts new file mode 100644 index 00000000000..7288f8092f1 --- /dev/null +++ b/src/main/ipc/preflight-runtime-target.ts @@ -0,0 +1,32 @@ +import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' +import type { WslPreflightTarget } from './preflight-wsl-agent-detection' + +export type PreflightRuntimeContext = { + wslDistro?: string | null + wslDefault?: boolean + projectRuntime?: ProjectExecutionRuntimeResolution +} + +export function getPreflightWslTarget( + context?: PreflightRuntimeContext, + platform: string = process.platform +): WslPreflightTarget | null { + if (platform !== 'win32') { + return null + } + if (context?.projectRuntime) { + if (context.projectRuntime.status === 'repair-required') { + throw new Error( + `Project runtime requires repair before preflight: ${context.projectRuntime.repair.reason}` + ) + } + return context.projectRuntime.runtime.kind === 'wsl' + ? { distro: context.projectRuntime.runtime.distro } + : null + } + const distro = context?.wslDistro?.trim() + if (distro) { + return { distro } + } + return context?.wslDefault ? {} : null +} diff --git a/src/main/ipc/preflight.test.ts b/src/main/ipc/preflight.test.ts index 462df8936d0..94b969848b2 100644 --- a/src/main/ipc/preflight.test.ts +++ b/src/main/ipc/preflight.test.ts @@ -710,6 +710,86 @@ describe('preflight', () => { ) }) + it('lets a resolved host project runtime override stale WSL context flags', async () => { + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + execFileAsyncMock.mockImplementation(async (command, args) => { + expect(command).not.toBe('wsl.exe') + if (command === 'git' || command === 'gh' || command === 'glab') { + return { stdout: `${String(command)} ok\n` } + } + throw new Error(`unexpected command ${String(command)} ${JSON.stringify(args)}`) + }) + + const status = await runPreflightCheck(false, { + wslDistro: 'Ubuntu', + projectRuntime: { + status: 'resolved', + runtime: { + kind: 'windows-host', + hostPlatform: 'win32', + projectId: 'project-1', + reason: 'project-override', + cacheKey: 'project-1:windows-host' + } + } + }) + + expect(status.git.installed).toBe(true) + expect(mergePersistedWindowsPathMock).toHaveBeenCalled() + }) + + it('does not hydrate the host PATH when refreshing agents for a resolved WSL runtime', async () => { + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + execFileAsyncMock.mockImplementation(async (command, args) => { + if (command !== 'wsl.exe') { + throw new Error(`unexpected command ${String(command)}`) + } + const script = String(args[5]) + if (script.includes("'claude'")) { + return { stdout: '__ORCA_AGENT_PATH__claude\t/home/test/.local/bin/claude\n' } + } + throw new Error('not found') + }) + + registerPreflightHandlers() + + const result = (await handlers['preflight:refreshAgents'](undefined, { + projectRuntime: { + status: 'resolved', + runtime: { + kind: 'wsl', + hostPlatform: 'wsl', + projectId: 'project-1', + distro: 'Ubuntu', + reason: 'project-override', + cacheKey: 'project-1:wsl:Ubuntu' + } + } + })) as { + agents: string[] + addedPathSegments: string[] + shellHydrationOk: boolean + pathSource: string + pathFailureReason: string + } + + expect(result).toEqual({ + agents: ['claude'], + addedPathSegments: [], + shellHydrationOk: true, + pathSource: 'sync_seed_only', + pathFailureReason: 'none' + }) + expect(hydrateShellPathMock).not.toHaveBeenCalled() + expect(mergePathSegmentsMock).not.toHaveBeenCalled() + }) + it('refreshes via preflight:refreshAgents by re-hydrating PATH before re-detecting', async () => { // Why: the Agents settings Refresh button calls this path. It must (1) ask // the shell hydrator for a fresh PATH, (2) merge any new segments, then diff --git a/src/main/ipc/preflight.ts b/src/main/ipc/preflight.ts index a2e7130b7bc..8d65e0acc4b 100644 --- a/src/main/ipc/preflight.ts +++ b/src/main/ipc/preflight.ts @@ -14,14 +14,10 @@ import { detectWslCommandsOnPath, type WslPreflightTarget } from './preflight-ws import { runPreflightCommandInWsl } from './preflight-wsl-command' import { detectCommandsInInstallDirs } from './local-agent-install-dir-detection' import { buildLocalPreflightEnv } from './preflight-local-env' +import { getPreflightWslTarget, type PreflightRuntimeContext } from './preflight-runtime-target' const execFileAsync = promisify(execFile) const PREFLIGHT_COMMAND_TIMEOUT_MS = 5000 -type PreflightRuntimeContext = { - wslDistro?: string | null - wslDefault?: boolean -} - export type PreflightStatus = { git: { installed: boolean } gh: { installed: boolean; authenticated: boolean } @@ -153,17 +149,6 @@ function uniqueAgentIds(ids: Iterable): string[] { return [...new Set(ids)] } -function getPreflightWslTarget(context?: PreflightRuntimeContext): WslPreflightTarget | null { - if (process.platform !== 'win32') { - return null - } - const distro = context?.wslDistro?.trim() - if (distro) { - return { distro } - } - return context?.wslDefault ? {} : null -} - async function detectCommandRuntime( command: string, context?: PreflightRuntimeContext @@ -236,6 +221,17 @@ export type RefreshAgentsResult = { export async function refreshShellPathAndDetectAgents( context?: PreflightRuntimeContext ): Promise { + if (getPreflightWslTarget(context)) { + const agents = await detectInstalledAgents(context) + return { + agents, + addedPathSegments: [], + shellHydrationOk: true, + pathSource: 'sync_seed_only', + pathFailureReason: 'none' + } + } + const hydration = await hydrateShellPath({ force: true }) const added = hydration.ok ? mergePathSegments(hydration.segments) : [] const agents = await detectInstalledAgents(context) diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 920ecedaf12..9e6d1f321bb 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -4,6 +4,19 @@ assertion reuses the same mocked IPC and node-pty harness. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { delimiter, join } from 'node:path' +const isWindowsHost = process.platform === 'win32' +const posixOnlyIt = isWindowsHost ? it.skip : it +const expectedOmpStatusExtension = join( + '/tmp/orca-pi-agent-overlay', + 'extensions', + 'orca-agent-status.ts' +) +const expectedAttributionShimDir = join( + '/tmp/orca-user-data', + 'orca-terminal-attribution', + isWindowsHost ? 'win32' : 'posix' +) + const { handleMock, onMock, @@ -166,6 +179,7 @@ import { getPowerShellOsc133Bootstrap } from '../powershell-osc133-bootstrap' import { SSH_SESSION_EXPIRED_ERROR } from '../providers/ssh-pty-provider' +import { _resetWslCachesForTests, _setWslCachesForTests } from '../wsl' const POWERSHELL_OSC133_ARGS = [ '-NoLogo', @@ -298,6 +312,7 @@ describe('registerPtyHandlers', () => { }) afterEach(() => { + _resetWslCachesForTests() vi.useRealTimers() unregisterSshPtyProvider('ssh-1') setLocalPtyProvider(new LocalPtyProvider()) @@ -720,36 +735,39 @@ describe('registerPtyHandlers', () => { expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBeUndefined() }) - it('reproduces issue #1534: GUI-launched Orca mirrors zshrc-only OpenCode config', async () => { - // Why: the reporter's app process did not inherit OPENCODE_CONFIG_DIR; - // their interactive zsh startup later exported a company config repo. - readFileSyncMock.mockImplementation((path: string) => { - if (path.endsWith('.zshrc')) { - return [ - '# Company-wide OpenCode config loaded by interactive shells', - 'export OPENCODE_CONFIG_DIR="$HOME/company/opencode-config"', - '' - ].join('\n') - } - return '' - }) + posixOnlyIt( + 'reproduces issue #1534: GUI-launched Orca mirrors zshrc-only OpenCode config', + async () => { + // Why: the reporter's app process did not inherit OPENCODE_CONFIG_DIR; + // their interactive zsh startup later exported a company config repo. + readFileSyncMock.mockImplementation((path: string) => { + if (path.endsWith('.zshrc')) { + return [ + '# Company-wide OpenCode config loaded by interactive shells', + 'export OPENCODE_CONFIG_DIR="$HOME/company/opencode-config"', + '' + ].join('\n') + } + return '' + }) - const env = await spawnAndGetEnv(undefined, { - HOME: '/home/pim', - SHELL: '/bin/zsh', - OPENCODE_CONFIG_DIR: undefined, - ORCA_OPENCODE_SOURCE_CONFIG_DIR: undefined - }) + const env = await spawnAndGetEnv(undefined, { + HOME: '/home/pim', + SHELL: '/bin/zsh', + OPENCODE_CONFIG_DIR: undefined, + ORCA_OPENCODE_SOURCE_CONFIG_DIR: undefined + }) - expect(openCodeBuildPtyEnvMock).toHaveBeenCalledWith( - expect.any(String), - '/home/pim/company/opencode-config' - ) - expect(env.OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-overlay') - expect(env.ORCA_OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-overlay') - expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBe('/home/pim/company/opencode-config') - expect(env.OPENCODE_CONFIG_DIR).not.toBe(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR) - }) + expect(openCodeBuildPtyEnvMock).toHaveBeenCalledWith( + expect.any(String), + '/home/pim/company/opencode-config' + ) + expect(env.OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-overlay') + expect(env.ORCA_OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-overlay') + expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBe('/home/pim/company/opencode-config') + expect(env.OPENCODE_CONFIG_DIR).not.toBe(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR) + } + ) it('injects the Pi agent overlay env into Orca terminal PTYs', async () => { const env = await spawnAndGetEnv(undefined, { PI_CODING_AGENT_DIR: '/tmp/user-pi-agent' }) @@ -759,9 +777,7 @@ describe('registerPtyHandlers', () => { expect(env.ORCA_PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay') expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBe('/tmp/user-pi-agent') expect(env.ORCA_OMP_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay') - expect(env.ORCA_OMP_STATUS_EXTENSION).toBe( - '/tmp/orca-pi-agent-overlay/extensions/orca-agent-status.ts' - ) + expect(env.ORCA_OMP_STATUS_EXTENSION).toBe(expectedOmpStatusExtension) }) it('threads command: "omp" through to piBuildPtyEnv and emits ORCA_OMP_* shadow vars', async () => { @@ -784,9 +800,7 @@ describe('registerPtyHandlers', () => { ) expect(env.PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay') expect(env.ORCA_OMP_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay') - expect(env.ORCA_OMP_STATUS_EXTENSION).toBe( - '/tmp/orca-pi-agent-overlay/extensions/orca-agent-status.ts' - ) + expect(env.ORCA_OMP_STATUS_EXTENSION).toBe(expectedOmpStatusExtension) expect(env.ORCA_OMP_SOURCE_AGENT_DIR).toBe('/tmp/user-omp-agent') // CRITICAL: a Pi-named shadow MUST NOT leak into an OMP PTY env. expect(env.ORCA_PI_CODING_AGENT_DIR).toBeUndefined() @@ -863,7 +877,7 @@ describe('registerPtyHandlers', () => { expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined() }) - it('mirrors Pi config exported only by shell startup files', async () => { + posixOnlyIt('mirrors Pi config exported only by shell startup files', async () => { readFileSyncMock.mockImplementation((path: string) => path.endsWith('.zshrc') ? 'export PI_CODING_AGENT_DIR="$HOME/.config/pi-agent"\n' : '' ) @@ -943,7 +957,7 @@ describe('registerPtyHandlers', () => { expect(env.ORCA_GIT_COMMIT_TRAILER).toBe('Co-authored-by: Orca ') expect(env.ORCA_GH_PR_FOOTER).toBe('Made with [Orca](https://github.com/stablyai/orca) 🐋') expect(env.ORCA_GH_ISSUE_FOOTER).toBe('Made with [Orca](https://github.com/stablyai/orca) 🐋') - expect(env.PATH).toContain('/tmp/orca-user-data/orca-terminal-attribution/posix') + expect(env.PATH).toContain(expectedAttributionShimDir) }) it('skips git/gh attribution shims when attribution is disabled', async () => { @@ -955,7 +969,7 @@ describe('registerPtyHandlers', () => { expect(env.ORCA_GIT_COMMIT_TRAILER).toBeUndefined() expect(env.ORCA_GH_PR_FOOTER).toBeUndefined() expect(env.ORCA_GH_ISSUE_FOOTER).toBeUndefined() - expect(env.PATH ?? '').not.toContain('/tmp/orca-user-data/orca-terminal-attribution/posix') + expect(env.PATH ?? '').not.toContain(expectedAttributionShimDir) }) it('prepends git/gh attribution shims for daemon-backed local PTYs', async () => { @@ -984,7 +998,7 @@ describe('registerPtyHandlers', () => { const env = daemonSpawn.mock.calls.at(-1)![0].env expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBe('1') - expect(env.PATH).toContain('/tmp/orca-user-data/orca-terminal-attribution/posix') + expect(env.PATH).toContain(expectedAttributionShimDir) }) it('overrides ambient CODEX_HOME with the Orca-managed home for system default', async () => { @@ -1046,6 +1060,47 @@ describe('registerPtyHandlers', () => { env: Record envToDelete?: string[] isNewSession?: boolean + shellOverride?: string + terminalWindowsWslDistro?: string | null + terminalWindowsPowerShellImplementation?: string + } + + async function withWin32Platform(fn: () => Promise): Promise { + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + try { + return await fn() + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + } + + function makeProjectRuntimeStore(args: { + projectRuntimePreference: unknown + settings?: Record + }) { + const settings = { + localWindowsRuntimeDefault: { kind: 'windows-host' }, + ...args.settings + } + return { + getRepo: vi.fn((repoId: string) => + repoId === 'repo-1' ? { id: 'repo-1', path: 'C:\\repo' } : undefined + ), + getProjects: vi.fn(() => [ + { + id: 'project-1', + sourceRepoIds: ['repo-1'], + localWindowsRuntimePreference: args.projectRuntimePreference + } + ]), + getSettings: vi.fn(() => settings) + } } async function daemonSpawnAndGetOptions( @@ -1173,9 +1228,7 @@ describe('registerPtyHandlers', () => { expect(env.ORCA_PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay') expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBe('/user/.pi/agent') expect(env.ORCA_OMP_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay') - expect(env.ORCA_OMP_STATUS_EXTENSION).toBe( - '/tmp/orca-pi-agent-overlay/extensions/orca-agent-status.ts' - ) + expect(env.ORCA_OMP_STATUS_EXTENSION).toBe(expectedOmpStatusExtension) }) it('threads command: "omp" through to piBuildPtyEnv on the daemon path with OMP shadow vars', async () => { @@ -1196,9 +1249,7 @@ describe('registerPtyHandlers', () => { ) expect(env.PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay') expect(env.ORCA_OMP_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay') - expect(env.ORCA_OMP_STATUS_EXTENSION).toBe( - '/tmp/orca-pi-agent-overlay/extensions/orca-agent-status.ts' - ) + expect(env.ORCA_OMP_STATUS_EXTENSION).toBe(expectedOmpStatusExtension) expect(env.ORCA_OMP_SOURCE_AGENT_DIR).toBe('/user/.omp/agent') expect(env.ORCA_PI_CODING_AGENT_DIR).toBeUndefined() expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined() @@ -1337,6 +1388,114 @@ describe('registerPtyHandlers', () => { expect(spawnOptions.env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token') }) + it('uses the owning project WSL runtime for runtime-created daemon PTYs', async () => { + await withWin32Platform(async () => { + _setWslCachesForTests({ available: true, distros: ['Ubuntu'] }) + const daemonSpawn = setupDaemonAdapter() + const runtime = { + setPtyController: vi.fn(), + registerPty: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + const settings = { + localWindowsRuntimeDefault: { kind: 'windows-host' }, + terminalWindowsShell: 'powershell.exe', + terminalWindowsWslDistro: 'Debian', + terminalWindowsPowerShellImplementation: 'auto' + } + const store = makeProjectRuntimeStore({ + projectRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }, + settings + }) + handlers.clear() + registerPtyHandlers( + mainWindow as never, + runtime as never, + undefined, + (() => settings) as never, + undefined, + store as never + ) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as { + spawn(args: { + cols: number + rows: number + cwd?: string + worktreeId?: string + env?: Record + }): Promise<{ id: string }> + } + + await controller.spawn({ + cols: 80, + rows: 24, + cwd: 'C:\\repo', + worktreeId: 'repo-1::C:\\repo', + env: {} + }) + + const spawnOptions = daemonSpawn.mock.calls.at(-1)?.[0] as DaemonSpawnCall + expect(spawnOptions.shellOverride).toBe('wsl.exe') + expect(spawnOptions.terminalWindowsWslDistro).toBe('Ubuntu') + expect(spawnOptions.terminalWindowsPowerShellImplementation).toBe('auto') + }) + }) + + it('blocks runtime-created daemon PTYs when project WSL runtime requires repair', async () => { + await withWin32Platform(async () => { + _setWslCachesForTests({ available: true, distros: ['Debian'] }) + const daemonSpawn = setupDaemonAdapter() + const runtime = { + setPtyController: vi.fn(), + registerPty: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + const settings = { + localWindowsRuntimeDefault: { kind: 'windows-host' }, + terminalWindowsShell: 'powershell.exe' + } + const store = makeProjectRuntimeStore({ + projectRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }, + settings + }) + handlers.clear() + registerPtyHandlers( + mainWindow as never, + runtime as never, + undefined, + (() => settings) as never, + undefined, + store as never + ) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as { + spawn(args: { + cols: number + rows: number + cwd?: string + worktreeId?: string + env?: Record + }): Promise<{ id: string }> + } + + await expect( + controller.spawn({ + cols: 80, + rows: 24, + cwd: 'C:\\repo', + worktreeId: 'repo-1::C:\\repo', + env: {} + }) + ).rejects.toThrow( + 'Project runtime requires repair before terminal spawn: wsl-distro-missing' + ) + expect(daemonSpawn).not.toHaveBeenCalled() + }) + }) + it('keeps the Agent Teams tmux shim ahead of host PATH shims for runtime-created daemon PTYs', async () => { type RuntimeSpawnController = { spawn(args: { @@ -1378,9 +1537,7 @@ describe('registerPtyHandlers', () => { const spawnOptions = daemonSpawn.mock.calls.at(-1)?.[0] as DaemonSpawnCall expect(spawnOptions.env.PATH.split(delimiter)[0]).toBe('/tmp/orca-agent-teams-bin') - expect(spawnOptions.env.PATH).toContain( - '/tmp/orca-user-data/orca-terminal-attribution/posix' - ) + expect(spawnOptions.env.PATH).toContain(expectedAttributionShimDir) expect(spawnOptions.env.TERM_PROGRAM).toBeUndefined() expect(spawnOptions.env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined() expect(spawnOptions.envToDelete).toEqual( @@ -1410,7 +1567,7 @@ describe('registerPtyHandlers', () => { enableGitHubAttribution: true })) expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBe('1') - expect(env.PATH).toContain('/tmp/orca-user-data/orca-terminal-attribution/posix') + expect(env.PATH).toContain(expectedAttributionShimDir) }) it('keeps the Agent Teams tmux shim ahead of host PATH shims on daemon pty:spawn', async () => { @@ -1431,9 +1588,7 @@ describe('registerPtyHandlers', () => { ) expect(spawnOptions.env.PATH.split(delimiter)[0]).toBe('/tmp/orca-agent-teams-bin') - expect(spawnOptions.env.PATH).toContain( - '/tmp/orca-user-data/orca-terminal-attribution/posix' - ) + expect(spawnOptions.env.PATH).toContain(expectedAttributionShimDir) expect(spawnOptions.env.TERM_PROGRAM).toBeUndefined() expect(spawnOptions.env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined() expect(spawnOptions.envToDelete).toEqual( @@ -1467,7 +1622,7 @@ describe('registerPtyHandlers', () => { PATH: '/system/bin' }) expect(env.ORCA_USER_DATA_PATH).toBe('/tmp/orca-user-data') - expect(env.PATH).toBe( + expect(env.PATH).toContain( `${join('/tmp/orca-user-data', 'cli', 'bin')}${delimiter}/system/bin` ) } finally { @@ -1549,7 +1704,7 @@ describe('registerPtyHandlers', () => { enableGitHubAttribution: false })) expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBeUndefined() - expect(env.PATH ?? '').not.toContain('/tmp/orca-user-data/orca-terminal-attribution/posix') + expect(env.PATH ?? '').not.toContain(expectedAttributionShimDir) }) it('does not mutate the caller-provided args.env on the daemon path', async () => { @@ -4147,6 +4302,108 @@ describe('registerPtyHandlers', () => { ) }) + it('uses the host shell when resolved project runtime overrides a stale WSL shell default', async () => { + process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe' + + registerPtyHandlers( + mainWindow as never, + undefined, + undefined, + () => + ({ + terminalWindowsShell: 'wsl.exe', + terminalWindowsWslDistro: 'Debian' + }) as never + ) + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + projectRuntime: { + status: 'resolved', + runtime: { + kind: 'windows-host', + hostPlatform: 'win32', + projectId: 'repo-1', + source: 'project-override', + cacheKey: 'repo-1:windows-host' + } + } + }) + + expect(spawnMock).toHaveBeenCalledWith( + 'C:\\Windows\\system32\\cmd.exe', + ['/K', 'chcp 65001 > nul'], + expect.any(Object) + ) + }) + + it('uses the selected project WSL distro when resolved runtime overrides the host shell default', async () => { + process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe' + + registerPtyHandlers( + mainWindow as never, + undefined, + undefined, + () => + ({ + terminalWindowsShell: 'powershell.exe', + terminalWindowsWslDistro: 'Debian' + }) as never + ) + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: 'C:\\Users\\test\\repo', + projectRuntime: { + status: 'resolved', + runtime: { + kind: 'wsl', + hostPlatform: 'wsl', + projectId: 'repo-1', + distro: 'Ubuntu', + source: 'project-override', + cacheKey: 'repo-1:wsl:Ubuntu' + } + } + }) + + const spawnCall = spawnMock.mock.calls.at(-1)! + expect(spawnCall[0]).toBe('wsl.exe') + expect(spawnCall[1]).toEqual(expect.arrayContaining(['-d', 'Ubuntu'])) + }) + + it('blocks terminal spawn when project runtime requires repair', async () => { + process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe' + + registerPtyHandlers( + mainWindow as never, + undefined, + undefined, + () => + ({ + terminalWindowsShell: 'powershell.exe' + }) as never + ) + + await expect( + handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + projectRuntime: { + status: 'repair-required', + repair: { + projectId: 'repo-1', + reason: 'wsl-distro-missing', + requestedDistro: 'Ubuntu', + fallbackRuntime: null, + cacheKey: 'repo-1:repair:wsl-distro-missing:Ubuntu' + } + } + }) + ).rejects.toThrow('Project runtime requires repair before terminal spawn') + expect(spawnMock).not.toHaveBeenCalled() + }) + it('spawns powershell.exe when PowerShell family keeps the inbox implementation', async () => { process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe' @@ -4491,36 +4748,39 @@ describe('registerPtyHandlers', () => { } }) - it('does not write the startup command before the shell-ready marker arrives', async () => { - vi.useFakeTimers() - const mockProc = createMockProc() - spawnMock.mockReturnValue(mockProc.proc) + posixOnlyIt( + 'does not write the startup command before the shell-ready marker arrives', + async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) - try { - registerPtyHandlers(mainWindow as never) - await handlers.get('pty:spawn')!(null, { - cols: 80, - rows: 24, - cwd: '/tmp', - command: 'claude' - }) + try { + registerPtyHandlers(mainWindow as never) + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp', + command: 'claude' + }) - expect(mockProc.proc.write).not.toHaveBeenCalled() + expect(mockProc.proc.write).not.toHaveBeenCalled() - mockProc.emitData('last login: today\r\n') - vi.runOnlyPendingTimers() - expect(mockProc.proc.write).not.toHaveBeenCalled() + mockProc.emitData('last login: today\r\n') + vi.runOnlyPendingTimers() + expect(mockProc.proc.write).not.toHaveBeenCalled() - mockProc.emitData('\x1b]133;A\x07% ') - await Promise.resolve() - vi.runAllTimers() - expect(mockProc.proc.write).toHaveBeenCalledWith('claude\n') - } finally { - vi.useRealTimers() + mockProc.emitData('\x1b]133;A\x07% ') + await Promise.resolve() + vi.runAllTimers() + expect(mockProc.proc.write).toHaveBeenCalledWith('claude\n') + } finally { + vi.useRealTimers() + } } - }) + ) - it('falls back to a max wait when the shell emits no readiness output', async () => { + posixOnlyIt('falls back to a max wait when the shell emits no readiness output', async () => { vi.useFakeTimers() const mockProc = createMockProc() spawnMock.mockReturnValue(mockProc.proc) @@ -5209,7 +5469,7 @@ describe('registerPtyHandlers', () => { } }) - it('falls back to a system shell when SHELL points to a missing binary', async () => { + posixOnlyIt('falls back to a system shell when SHELL points to a missing binary', async () => { const originalShell = process.env.SHELL const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) @@ -5247,7 +5507,7 @@ describe('registerPtyHandlers', () => { } }) - it('falls back when SHELL points to a non-executable binary', async () => { + posixOnlyIt('falls back when SHELL points to a non-executable binary', async () => { const originalShell = process.env.SHELL const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) @@ -5465,7 +5725,7 @@ describe('registerPtyHandlers', () => { expect(cleanupOptions?.shouldClearStablePaneKey(stablePaneKey)).toBe(false) }) - it('prefers args.env.SHELL and normalizes the child env after fallback', async () => { + posixOnlyIt('prefers args.env.SHELL and normalizes the child env after fallback', async () => { const originalShell = process.env.SHELL const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 894e93ac201..9036abe2b88 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -10,6 +10,11 @@ export { getBashShellReadyRcfileContent } from '../providers/local-pty-shell-rea import type { OrcaRuntimeService } from '../runtime/orca-runtime' import type { Store } from '../persistence' import type { GlobalSettings } from '../../shared/types' +import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' +import { + isWslShellName, + resolveLocalWindowsTerminalRuntimeOptions +} from '../../shared/local-windows-terminal-runtime' import { openCodeHookService } from '../opencode/hook-service' import { agentHookServer } from '../agent-hooks/server' import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' @@ -70,6 +75,7 @@ import { getFolderWorkspacePathStatus } from '../project-groups/folder-workspace-path-status' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' +import { resolveLocalProjectRuntimeForWorktreeId } from '../local-project-runtime-resolution' // ─── Provider Registry ────────────────────────────────────────────── // Routes PTY operations by connectionId. null = local provider. @@ -412,11 +418,6 @@ function deleteRequestedEnvKeys( } } -function isWslShellName(shellPath: string | undefined): boolean { - const shellName = shellPath?.replaceAll('\\', '/').split('/').pop()?.toLowerCase() - return shellName === 'wsl.exe' || shellName === 'wsl' -} - function shouldSkipCodexHomeEnvForWindowsShell( shellPath: string | undefined, cwd: string | undefined @@ -1644,14 +1645,22 @@ export function registerPtyHandlers( if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) { throw new Error('A Claude account switch is in progress. Try again after it finishes.') } - const daemonShellOverride = + // Why: runtime-created terminals do not carry renderer-computed + // projectRuntime, so resolve from worktreeId to honor project Windows runtime. + const terminalRuntimeOptions = process.platform === 'win32' && !args.connectionId - ? getSettings?.()?.terminalWindowsShell - : undefined + ? resolveLocalWindowsTerminalRuntimeOptions({ + requestedShellOverride: undefined, + settings: getSettings?.(), + projectRuntime: resolveLocalProjectRuntimeForWorktreeId(store, args.worktreeId), + fallbackHostShell: process.env.COMSPEC || 'powershell.exe' + }) + : { shellOverride: undefined, terminalWindowsWslDistro: null } + const daemonShellOverride = terminalRuntimeOptions.shellOverride const codexSelectionTarget = getCodexSelectionTargetForPty( daemonShellOverride, args.cwd, - getSettings?.()?.terminalWindowsWslDistro ?? null + terminalRuntimeOptions.terminalWindowsWslDistro ?? null ) const claudeAuth = isClaudeLaunch && prepareClaudeAuth ? await prepareClaudeAuth(codexSelectionTarget) : null @@ -1769,8 +1778,9 @@ export function registerPtyHandlers( ptySizes.set(effectiveSessionAppId ?? sessionId, { cols: args.cols, rows: args.rows }) } if (process.platform === 'win32' && !args.connectionId) { - spawnOptions.shellOverride = getSettings?.()?.terminalWindowsShell - spawnOptions.terminalWindowsWslDistro = getSettings?.()?.terminalWindowsWslDistro ?? null + spawnOptions.shellOverride = terminalRuntimeOptions.shellOverride + spawnOptions.terminalWindowsWslDistro = + terminalRuntimeOptions.terminalWindowsWslDistro ?? null spawnOptions.terminalWindowsPowerShellImplementation = getSettings ? (getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto') : undefined @@ -2112,6 +2122,7 @@ export function registerPtyHandlers( worktreeId?: string sessionId?: string shellOverride?: string + projectRuntime?: ProjectExecutionRuntimeResolution // Why: closes the SIGKILL race documented in INVESTIGATION.md by // letting main patch + sync-flush the (worktreeId, tabId, leafId → // ptyId) binding before pty:spawn returns. Only the renderer's @@ -2143,15 +2154,20 @@ export function registerPtyHandlers( if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) { throw new Error('A Claude account switch is in progress. Try again after it finishes.') } - const initialShellOverride = - args.shellOverride ?? - (process.platform === 'win32' && !args.connectionId - ? getSettings?.()?.terminalWindowsShell - : undefined) + const terminalRuntimeOptions = + process.platform === 'win32' && !args.connectionId + ? resolveLocalWindowsTerminalRuntimeOptions({ + requestedShellOverride: args.shellOverride, + settings: getSettings?.(), + projectRuntime: args.projectRuntime, + fallbackHostShell: process.env.COMSPEC || 'powershell.exe' + }) + : { shellOverride: args.shellOverride, terminalWindowsWslDistro: null } + const initialShellOverride = terminalRuntimeOptions.shellOverride const initialSelectionTarget = getCodexSelectionTargetForPty( initialShellOverride, args.cwd, - getSettings?.()?.terminalWindowsWslDistro ?? null + terminalRuntimeOptions.terminalWindowsWslDistro ?? null ) const claudeAuth = isClaudeLaunch && prepareClaudeAuth ? await prepareClaudeAuth(initialSelectionTarget) : null @@ -2261,15 +2277,11 @@ export function registerPtyHandlers( runtime && !(provider instanceof LocalPtyProvider) ? runtime.createPreAllocatedTerminalHandle() : null - const effectiveShellOverride = - args.shellOverride ?? - (process.platform === 'win32' && !args.connectionId - ? getSettings?.()?.terminalWindowsShell - : undefined) + const effectiveShellOverride = terminalRuntimeOptions.shellOverride const codexSelectionTarget = getCodexSelectionTargetForPty( effectiveShellOverride, args.cwd, - getSettings?.()?.terminalWindowsWslDistro ?? null + terminalRuntimeOptions.terminalWindowsWslDistro ?? null ) const selectedCodexHomePath = isDaemonHostSpawn ? getCompatibleSelectedCodexHomePath( @@ -2386,7 +2398,8 @@ export function registerPtyHandlers( // the persisted implementation choice through spawnOptions so both the // in-process and daemon-backed PTY paths can resolve the same effective // executable without inventing a fourth top-level shell. - spawnOptions.terminalWindowsWslDistro = getSettings?.()?.terminalWindowsWslDistro ?? null + spawnOptions.terminalWindowsWslDistro = + terminalRuntimeOptions.terminalWindowsWslDistro ?? null spawnOptions.terminalWindowsPowerShellImplementation = getSettings ? (getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto') : undefined diff --git a/src/main/ipc/repos-create.test.ts b/src/main/ipc/repos-create.test.ts index dfb898f7a5c..5bd8acdf834 100644 --- a/src/main/ipc/repos-create.test.ts +++ b/src/main/ipc/repos-create.test.ts @@ -10,6 +10,7 @@ */ import { describe, expect, it, vi, beforeEach } from 'vitest' +import { join } from 'path' import { DEFAULT_REPO_BADGE_COLOR } from '../../shared/constants' const { @@ -105,6 +106,8 @@ describe('repos:create', () => { isDestroyed: () => false, webContents: { send: vi.fn() } } + const tmpPath = (...segments: string[]): string => join('/tmp', ...segments) + const defaultProjectParent = join('/Users/alice', 'orca', 'projects') const callCreate = (args: CreateArgs): Promise => { const handler = handlers.get('repos:create') @@ -151,7 +154,7 @@ describe('repos:create', () => { it('registers the home-backed create-project default handler', async () => { expect(handlers.has('repos:getDefaultCreateProjectParent')).toBe(true) - await expect(callDefaultCreateProjectParent()).resolves.toBe('/Users/alice/orca/projects') + await expect(callDefaultCreateProjectParent()).resolves.toBe(defaultProjectParent) }) it('unregisters any previously-registered repos:create handler', () => { @@ -220,7 +223,7 @@ describe('repos:create', () => { expect(mkdirMock).toHaveBeenCalledWith('/tmp', { recursive: true }) expect(mkdirMock).not.toHaveBeenCalledWith('/tmp/empty', expect.anything()) expect(mockStore.addRepo).toHaveBeenCalledWith( - expect.objectContaining({ path: '/tmp/empty', kind: 'folder' }) + expect.objectContaining({ path: tmpPath('empty'), kind: 'folder' }) ) expect(result).toHaveProperty('repo.kind', 'folder') }) @@ -230,23 +233,23 @@ describe('repos:create', () => { await callCreate({ parentPath: '/tmp', name: 'brand-new', kind: 'folder' }) expect(mkdirMock).toHaveBeenNthCalledWith(1, '/tmp', { recursive: true }) - expect(mkdirMock).toHaveBeenNthCalledWith(2, '/tmp/brand-new', { recursive: false }) + expect(mkdirMock).toHaveBeenNthCalledWith(2, tmpPath('brand-new'), { recursive: false }) }) it('creates a missing default parent before creating the project directory', async () => { const result = await callCreate({ - parentPath: '/Users/alice/orca/projects', + parentPath: defaultProjectParent, name: 'first-project', kind: 'folder' }) - expect(mkdirMock).toHaveBeenNthCalledWith(1, '/Users/alice/orca/projects', { + expect(mkdirMock).toHaveBeenNthCalledWith(1, defaultProjectParent, { recursive: true }) - expect(mkdirMock).toHaveBeenNthCalledWith(2, '/Users/alice/orca/projects/first-project', { + expect(mkdirMock).toHaveBeenNthCalledWith(2, join(defaultProjectParent, 'first-project'), { recursive: false }) - expect(result).toHaveProperty('repo.path', '/Users/alice/orca/projects/first-project') + expect(result).toHaveProperty('repo.path', join(defaultProjectParent, 'first-project')) }) // ── plain folder happy path ─────────────────────────────────────── @@ -257,7 +260,7 @@ describe('repos:create', () => { expect(gitExecFileAsyncMock).not.toHaveBeenCalled() expect(mockStore.addRepo).toHaveBeenCalledWith( expect.objectContaining({ - path: '/tmp/plain', + path: tmpPath('plain'), displayName: 'plain', kind: 'folder' }) @@ -280,16 +283,16 @@ describe('repos:create', () => { const result = await callCreate({ parentPath: '/tmp', name: 'gitproj', kind: 'git' }) expect(mkdirMock).toHaveBeenNthCalledWith(1, '/tmp', { recursive: true }) - expect(mkdirMock).toHaveBeenNthCalledWith(2, '/tmp/gitproj', { recursive: false }) - expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(1, ['init'], { cwd: '/tmp/gitproj' }) + expect(mkdirMock).toHaveBeenNthCalledWith(2, tmpPath('gitproj'), { recursive: false }) + expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(1, ['init'], { cwd: tmpPath('gitproj') }) expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith( 2, ['commit', '--allow-empty', '-m', 'Initial commit'], - { cwd: '/tmp/gitproj' } + { cwd: tmpPath('gitproj') } ) expect(mockStore.addRepo).toHaveBeenCalledWith( expect.objectContaining({ - path: '/tmp/gitproj', + path: tmpPath('gitproj'), displayName: 'gitproj', kind: 'git' }) @@ -304,7 +307,7 @@ describe('repos:create', () => { const result = await callCreate({ parentPath: '/tmp', name: 'broken', kind: 'git' }) - expect(rmMock).toHaveBeenCalledWith('/tmp/broken', { recursive: true, force: true }) + expect(rmMock).toHaveBeenCalledWith(tmpPath('broken'), { recursive: true, force: true }) expect(mockStore.addRepo).not.toHaveBeenCalled() expect(result).toMatchObject({ error: expect.stringContaining('Failed to initialize') }) }) @@ -328,7 +331,7 @@ describe('repos:create', () => { const result = await callCreate({ parentPath: '/tmp', name: 'initfail', kind: 'git' }) - expect(rmMock).toHaveBeenCalledWith('/tmp/initfail', { recursive: true, force: true }) + expect(rmMock).toHaveBeenCalledWith(tmpPath('initfail'), { recursive: true, force: true }) expect(mockStore.addRepo).not.toHaveBeenCalled() // Loose match — handler distinguishes init vs commit failures, and we want // to tolerate small wording tweaks as long as it still mentions "initialize". @@ -345,7 +348,7 @@ describe('repos:create', () => { const result = await callCreate({ parentPath: '/tmp', name: 'commitfail', kind: 'git' }) - expect(rmMock).toHaveBeenCalledWith('/tmp/commitfail', { recursive: true, force: true }) + expect(rmMock).toHaveBeenCalledWith(tmpPath('commitfail'), { recursive: true, force: true }) expect(mockStore.addRepo).not.toHaveBeenCalled() expect(result).toMatchObject({ error: expect.stringContaining('commit') }) }) @@ -363,8 +366,14 @@ describe('repos:create', () => { const result = await callCreate({ parentPath: '/tmp', name: 'pre-existing', kind: 'git' }) - expect(rmMock).toHaveBeenCalledWith('/tmp/pre-existing/.git', { recursive: true, force: true }) - expect(rmMock).not.toHaveBeenCalledWith('/tmp/pre-existing', { recursive: true, force: true }) + expect(rmMock).toHaveBeenCalledWith(tmpPath('pre-existing', '.git'), { + recursive: true, + force: true + }) + expect(rmMock).not.toHaveBeenCalledWith(tmpPath('pre-existing'), { + recursive: true, + force: true + }) expect(mockStore.addRepo).not.toHaveBeenCalled() expect(result).toMatchObject({ error: expect.stringContaining('commit') }) }) @@ -381,7 +390,7 @@ describe('repos:create', () => { const result = await callCreate({ parentPath: '/tmp', name: 'authorless', kind: 'git' }) - expect(rmMock).toHaveBeenCalledWith('/tmp/authorless', { recursive: true, force: true }) + expect(rmMock).toHaveBeenCalledWith(tmpPath('authorless'), { recursive: true, force: true }) expect(mockStore.addRepo).not.toHaveBeenCalled() expect(result).toMatchObject({ error: expect.stringContaining('Git author identity is not configured') @@ -409,7 +418,7 @@ describe('repos:create', () => { expect(prepareLocalWorktreeRootForRepoMock).toHaveBeenCalledWith( mockStore, - expect.objectContaining({ path: '/tmp/root-prep', kind: 'git' }) + expect.objectContaining({ path: tmpPath('root-prep'), kind: 'git' }) ) }) @@ -420,7 +429,7 @@ describe('repos:create', () => { }) it('does NOT rebuild the authorized-roots cache when dedup short-circuits', async () => { - const existing = { id: 'abc', path: '/tmp/dupe2', displayName: 'dupe2', kind: 'git' } + const existing = { id: 'abc', path: tmpPath('dupe2'), displayName: 'dupe2', kind: 'git' } mockStore.getRepos.mockReturnValue([existing]) await callCreate({ parentPath: '/tmp', name: 'dupe2', kind: 'git' }) @@ -431,7 +440,7 @@ describe('repos:create', () => { // ── dedup-by-path ───────────────────────────────────────────────── it('returns the existing repo when one already lives at the target path', async () => { - const existing = { id: 'abc', path: '/tmp/dupe', displayName: 'dupe', kind: 'git' } + const existing = { id: 'abc', path: tmpPath('dupe'), displayName: 'dupe', kind: 'git' } mockStore.getRepos.mockReturnValue([existing]) const result = await callCreate({ parentPath: '/tmp', name: 'dupe', kind: 'git' }) @@ -446,7 +455,7 @@ describe('repos:create', () => { it('returns existing badgeColor unchanged on repos:create dedupe', async () => { const existing = { id: 'abc', - path: '/tmp/dupe-color', + path: tmpPath('dupe-color'), displayName: 'dupe-color', kind: 'git', badgeColor: '#ef4444' diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index ba55f01387d..100137b9fa7 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -9,10 +9,12 @@ import { z } from 'zod' import type { Store } from '../persistence' import type { BaseRefSearchResult, + Project, Repo, ProjectGroup, FolderWorkspace, ProjectGroupImportResult, + ProjectUpdateArgs, ProjectHostSetupCreateArgs, ProjectHostSetupCreateResult, ProjectHostSetupDeleteArgs, @@ -679,6 +681,19 @@ const ProjectHostSetupExistingFolderIpcArgs = z.object({ setupMethod: z.enum(['imported-existing-folder', 'cloned']).optional() }) +const LocalWindowsRuntimePreferenceIpcArgs = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('inherit-global') }), + z.object({ kind: z.literal('windows-host') }), + z.object({ kind: z.literal('wsl'), distro: z.string().min(1) }) +]) + +const ProjectUpdateIpcArgs = z.object({ + projectId: z.string().min(1), + updates: z.object({ + localWindowsRuntimePreference: LocalWindowsRuntimePreferenceIpcArgs.optional() + }) +}) + const ProjectHostSetupCreateIpcArgs = z.object({ projectId: z.string().min(1), hostId: z @@ -1082,6 +1097,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v ipcMain.removeHandler('repos:reorder') ipcMain.removeHandler('repos:update') ipcMain.removeHandler('projects:list') + ipcMain.removeHandler('projects:update') ipcMain.removeHandler('projectHostSetups:list') ipcMain.removeHandler('projectHostSetups:create') ipcMain.removeHandler('projectHostSetups:setupExistingFolder') @@ -1125,6 +1141,15 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v ipcMain.handle('projects:list', () => store.getProjects()) + ipcMain.handle('projects:update', (_event, rawArgs: ProjectUpdateArgs): Project | null => { + const args = parseProjectGroupIpcArgs( + ProjectUpdateIpcArgs, + rawArgs, + 'project_update_invalid_args' + ) + return store.updateProject(args.projectId, args.updates) + }) + ipcMain.handle('projectHostSetups:list', () => store.getProjectHostSetups()) ipcMain.handle( diff --git a/src/main/ipc/rg-availability.ts b/src/main/ipc/rg-availability.ts index 74c6e1e77a4..9dba16d582d 100644 --- a/src/main/ipc/rg-availability.ts +++ b/src/main/ipc/rg-availability.ts @@ -14,14 +14,14 @@ const RG_AVAILABILITY_TIMEOUT_MS = 5000 // while a positive cache could mask an rg that was uninstalled or broken // mid-session. -export function checkRgAvailable(searchPath?: string): Promise { +export function checkRgAvailable(searchPath?: string, wslDistro?: string): Promise { return new Promise((resolve) => { let settled = false - // Why: pass cwd so wslAwareSpawn routes through wsl.exe when the search - // path is inside a WSL filesystem. This checks whether rg is available - // inside the WSL distro rather than on the Windows PATH. + // Why: pass cwd plus project-runtime distro so WSL projects are checked + // inside their distro even when the search root is a Windows path. const child = wslAwareSpawn('rg', ['--version'], { ...(searchPath ? { cwd: searchPath } : {}), + ...(wslDistro ? { wslDistro } : {}), stdio: 'ignore' }) let timeout: ReturnType diff --git a/src/main/ipc/skills.test.ts b/src/main/ipc/skills.test.ts new file mode 100644 index 00000000000..b75b7048b08 --- /dev/null +++ b/src/main/ipc/skills.test.ts @@ -0,0 +1,131 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { handleMock, discoverSkillsMock, getDefaultWslDistroMock, getWslHomeMock } = vi.hoisted( + () => ({ + handleMock: vi.fn(), + discoverSkillsMock: vi.fn(), + getDefaultWslDistroMock: vi.fn(), + getWslHomeMock: vi.fn() + }) +) + +vi.mock('electron', () => ({ + ipcMain: { + handle: handleMock + } +})) + +vi.mock('../skills/discovery', () => ({ + discoverSkills: discoverSkillsMock +})) + +vi.mock('../wsl', () => ({ + getDefaultWslDistro: getDefaultWslDistroMock, + getWslHome: getWslHomeMock +})) + +import { registerSkillsHandlers } from './skills' + +describe('registerSkillsHandlers', () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + const repos = [{ id: 'repo-1', path: 'C:\\Users\\alice\\repo' }] + const store = { + getRepos: vi.fn(() => repos) + } + + beforeEach(() => { + handleMock.mockReset() + discoverSkillsMock.mockReset() + getDefaultWslDistroMock.mockReset() + getWslHomeMock.mockReset() + discoverSkillsMock.mockResolvedValue({ skills: [], sources: [], scannedAt: 1 }) + getWslHomeMock.mockReturnValue('\\\\wsl.localhost\\Ubuntu\\home\\alice') + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + }) + + afterEach(() => { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + }) + + function getDiscoverHandler() { + registerSkillsHandlers(store as never) + const call = handleMock.mock.calls.find((entry: unknown[]) => entry[0] === 'skills:discover') + if (!call) { + throw new Error('skills:discover handler was not registered') + } + return call[1] as (_event: unknown, target?: unknown) => Promise + } + + it('uses host skill discovery when resolved project runtime overrides stale WSL target state', async () => { + const handler = getDiscoverHandler() + + await handler(null, { + runtime: 'wsl', + wslDistro: 'Debian', + projectRuntime: { + status: 'resolved', + runtime: { + kind: 'windows-host', + hostPlatform: 'win32', + projectId: 'repo-1', + reason: 'project-override', + cacheKey: 'repo-1:windows-host' + } + } + }) + + expect(discoverSkillsMock).toHaveBeenCalledWith({ repos }) + expect(getWslHomeMock).not.toHaveBeenCalled() + }) + + it('uses the selected project WSL distro for skill discovery', async () => { + const handler = getDiscoverHandler() + + await handler(null, { + projectRuntime: { + status: 'resolved', + runtime: { + kind: 'wsl', + hostPlatform: 'wsl', + projectId: 'repo-1', + distro: 'Ubuntu', + reason: 'project-override', + cacheKey: 'repo-1:wsl:Ubuntu' + } + } + }) + + expect(getDefaultWslDistroMock).not.toHaveBeenCalled() + expect(getWslHomeMock).toHaveBeenCalledWith('Ubuntu') + expect(discoverSkillsMock).toHaveBeenCalledWith({ + repos: [], + homeDir: '\\\\wsl.localhost\\Ubuntu\\home\\alice', + cwd: '\\\\wsl.localhost\\Ubuntu\\home\\alice' + }) + }) + + it('blocks skill discovery when project runtime requires repair', async () => { + const handler = getDiscoverHandler() + + await expect( + handler(null, { + projectRuntime: { + status: 'repair-required', + repair: { + projectId: 'repo-1', + preferredRuntime: { kind: 'wsl', distro: 'Ubuntu' }, + reason: 'wsl-distro-missing', + source: 'project-override', + cacheKey: 'repo-1:repair:wsl-distro-missing:Ubuntu' + } + } + }) + ).rejects.toThrow('Project runtime requires repair before skill discovery') + expect(discoverSkillsMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/skills.ts b/src/main/ipc/skills.ts index 0a3611b27e9..e3f2e6a570b 100644 --- a/src/main/ipc/skills.ts +++ b/src/main/ipc/skills.ts @@ -4,15 +4,43 @@ import { discoverSkills } from '../skills/discovery' import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../../shared/skills' import { getDefaultWslDistro, getWslHome } from '../wsl' +type SkillDiscoveryRuntimeTarget = + | { runtime: 'host' } + | { runtime: 'wsl'; wslDistro: string | null | undefined } + +function getSkillDiscoveryRuntimeTarget( + target: SkillDiscoveryTarget | undefined +): SkillDiscoveryRuntimeTarget { + const projectRuntime = target?.projectRuntime + if (!projectRuntime) { + return target?.runtime === 'wsl' + ? { runtime: 'wsl', wslDistro: target.wslDistro } + : { runtime: 'host' } + } + + if (projectRuntime.status === 'repair-required') { + throw new Error( + `Project runtime requires repair before skill discovery: ${projectRuntime.repair.reason}` + ) + } + + if (projectRuntime.runtime.kind === 'wsl') { + return { runtime: 'wsl', wslDistro: projectRuntime.runtime.distro } + } + + return { runtime: 'host' } +} + export function registerSkillsHandlers(store: Store): void { ipcMain.handle( 'skills:discover', async (_event, target?: SkillDiscoveryTarget): Promise => { - if (target?.runtime === 'wsl') { + const runtimeTarget = getSkillDiscoveryRuntimeTarget(target) + if (runtimeTarget.runtime === 'wsl') { if (process.platform !== 'win32') { throw new Error('WSL skill discovery is only available on Windows.') } - const distro = target.wslDistro?.trim() || getDefaultWslDistro() + const distro = runtimeTarget.wslDistro?.trim() || getDefaultWslDistro() if (!distro) { throw new Error('No WSL distribution is available for skill discovery.') } diff --git a/src/main/ipc/worktree-logic.test.ts b/src/main/ipc/worktree-logic.test.ts index 29413f41033..cdf28a239dd 100644 --- a/src/main/ipc/worktree-logic.test.ts +++ b/src/main/ipc/worktree-logic.test.ts @@ -1,7 +1,7 @@ /* eslint-disable max-lines -- Why: these worktree path/name tests share a single setup-free pure-logic module, and splitting them would make the related edge cases harder to audit together. */ -import { join, resolve } from 'path' +import { posix, resolve } from 'path' import { describe, expect, it } from 'vitest' import { sanitizeWorktreeName, @@ -185,7 +185,7 @@ describe('computeWorktreePath', () => { nestWorkspaces: true, workspaceDir: '/workspaces' }) - ).toBe(join('/workspaces', 'my-project', 'feature')) + ).toBe(posix.join('/workspaces', 'my-project', 'feature')) }) it('uses flat layout when nestWorkspaces is false', () => { @@ -194,7 +194,7 @@ describe('computeWorktreePath', () => { nestWorkspaces: false, workspaceDir: '/workspaces' }) - ).toBe(join('/workspaces', 'feature')) + ).toBe(posix.join('/workspaces', 'feature')) }) it('strips .git suffix from repo path when nesting', () => { @@ -203,19 +203,19 @@ describe('computeWorktreePath', () => { nestWorkspaces: true, workspaceDir: '/workspaces' }) - ).toBe(join('/workspaces', 'my-project', 'feature')) + ).toBe(posix.join('/workspaces', 'my-project', 'feature')) }) it('resolves relative workspace directories from the repo path', () => { expect(computeWorkspaceRoot('/projects/app/repo', { workspaceDir: '../worktrees' })).toBe( - resolve('/projects/app/worktrees') + posix.resolve('/projects/app/worktrees') ) expect( computeWorktreePath('feature', '/projects/app/repo', { nestWorkspaces: false, workspaceDir: '../worktrees' }) - ).toBe(resolve('/projects/app/worktrees/feature')) + ).toBe(posix.resolve('/projects/app/worktrees/feature')) }) it('scopes the same relative repo override to each repo root', () => { @@ -225,10 +225,10 @@ describe('computeWorktreePath', () => { expect( computeWorktreePath('feature', repoA.path, getWorktreePathSettings(repoA, settings)) - ).toBe(resolve('/projects/a/worktrees/feature')) + ).toBe(posix.resolve('/projects/a/worktrees/feature')) expect( computeWorktreePath('feature', repoB.path, getWorktreePathSettings(repoB, settings)) - ).toBe(resolve('/projects/b/worktrees/feature')) + ).toBe(posix.resolve('/projects/b/worktrees/feature')) expect(getWorktreeCreationLayout(repoA, settings)).toEqual({ path: '../worktrees', nestWorkspaces: false @@ -294,6 +294,14 @@ describe('areWorktreePathsEqual', () => { expect(areWorktreePathsEqual('/tmp/Worktree', '/tmp/worktree', 'linux')).toBe(false) }) + it('keeps WSL-owned POSIX paths case-sensitive on Windows', () => { + expect(areWorktreePathsEqual('/home/dev/Repo', '/home/dev/repo', 'win32')).toBe(false) + }) + + it('does not collapse WSL POSIX paths with Windows drive paths', () => { + expect(areWorktreePathsEqual('/home/dev/repo', 'C:\\home\\dev\\repo', 'win32')).toBe(false) + }) + it('treats macOS /private/tmp git paths as matching /tmp workspace paths', () => { expect( areWorktreePathsEqual( diff --git a/src/main/ipc/worktree-logic.ts b/src/main/ipc/worktree-logic.ts index 3fbc32c83a9..cf0d380ea2e 100644 --- a/src/main/ipc/worktree-logic.ts +++ b/src/main/ipc/worktree-logic.ts @@ -166,6 +166,17 @@ export function areWorktreePathsEqual( rightPath: string, platform = process.platform ): boolean { + if (looksLikePosixAbsolutePath(leftPath) || looksLikePosixAbsolutePath(rightPath)) { + // Why: local WSL projects run POSIX paths on a Windows desktop; comparing + // them with win32 rules can delete or dedupe the wrong runtime-owned path. + if (!looksLikePosixAbsolutePath(leftPath) || !looksLikePosixAbsolutePath(rightPath)) { + return false + } + const left = normalizePosixWorktreePathForComparison(leftPath, platform) + const right = normalizePosixWorktreePathForComparison(rightPath, platform) + return left === right + } + if (platform === 'win32' || looksLikeWindowsPath(leftPath) || looksLikeWindowsPath(rightPath)) { const left = win32.normalize(win32.resolve(leftPath)) const right = win32.normalize(win32.resolve(rightPath)) @@ -186,6 +197,10 @@ function looksLikeWindowsPath(pathValue: string): boolean { ) } +function looksLikePosixAbsolutePath(pathValue: string): boolean { + return pathValue.startsWith('/') && !pathValue.startsWith('//') +} + function normalizePosixWorktreePathForComparison( pathValue: string, platform: NodeJS.Platform diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 9320722f4e9..e315368cc21 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -25,9 +25,9 @@ import type { } from '../../shared/types' import { getPRForBranch } from '../github/client' import { listWorktrees, addWorktree, addSparseWorktree } from '../git/worktree' -import type { AddWorktreeResult } from '../git/worktree' -import { hasCommitObjectViaGitExec, hasLocalCommitObject } from '../git/commit-object-ref' -import { getGitUsername, getDefaultBaseRef, getBranchConflictKind } from '../git/repo' +import type { AddWorktreeOptions, AddWorktreeResult } from '../git/worktree' +import { getGitUsername, getBranchConflictKind, resolveDefaultBaseRefViaExec } from '../git/repo' +import { hasCommitObjectViaGitExec } from '../git/commit-object-ref' import { getHostedReviewForBranch } from '../source-control/hosted-review' import type { ForgeProviderId } from '../source-control/forge-provider' import { validateGitPushTarget } from '../git/push-target-validation' @@ -94,6 +94,10 @@ import { markCopilotFolderTrusted, markCursorWorkspaceTrusted } from '../agent-trust-presets' +import { + getLocalProjectGitExecOptions, + getLocalProjectWorktreeGitOptions +} from '../project-runtime-git-options' const SSH_WORKTREE_CREATE_FETCH_FRESHNESS_MS = 30_000 const SSH_WORKTREE_CREATE_FETCH_CACHE_MAX = 512 @@ -452,7 +456,8 @@ async function resolveCreateBranchName( branchNameOverride: string | undefined, sanitizedName: string, settings: { branchPrefix: string; branchPrefixCustom?: string }, - username: string | null + username: string | null, + gitOptions: { wslDistro?: string } = {} ): Promise { if (!branchNameOverride) { return computeBranchName(sanitizedName, settings, username) @@ -460,7 +465,10 @@ async function resolveCreateBranchName( if (branchNameOverride.startsWith('-')) { throw new Error('Branch name must not start with "-"') } - await gitExecFileAsync(['check-ref-format', '--branch', branchNameOverride], { cwd: repoPath }) + await gitExecFileAsync(['check-ref-format', '--branch', branchNameOverride], { + cwd: repoPath, + ...gitOptions + }) return branchNameOverride } @@ -489,14 +497,16 @@ function normalizeLocalBranchName(branchName: string | undefined): string { async function canCheckoutExistingLocalBranch( repoPath: string, branchName: string, - baseBranch: string + baseBranch: string, + gitOptions: { wslDistro?: string } = {} ): Promise { let localHead = '' try { const { stdout } = await gitExecFileAsync( ['rev-parse', '--verify', '--quiet', `refs/heads/${branchName}^{commit}`], { - cwd: repoPath + cwd: repoPath, + ...gitOptions } ) localHead = stdout.trim() @@ -510,7 +520,7 @@ async function canCheckoutExistingLocalBranch( try { const { stdout } = await gitExecFileAsync( ['rev-parse', '--verify', '--quiet', `${baseBranch}^{commit}`], - { cwd: repoPath } + { cwd: repoPath, ...gitOptions } ) if (stdout.trim() !== localHead) { return false @@ -519,10 +529,35 @@ async function canCheckoutExistingLocalBranch( return false } } - const worktrees = await listWorktrees(repoPath) + const worktrees = await listWorktrees(repoPath, gitOptions) return !worktrees.some((worktree) => normalizeLocalBranchName(worktree.branch) === branchName) } +function hasLocalGitOptions(gitOptions: { wslDistro?: string }): boolean { + return Object.keys(gitOptions).length > 0 +} + +function hasLocalCommitObjectWithOptions( + repoPath: string, + ref: string, + gitOptions: { wslDistro?: string } +): Promise { + return hasCommitObjectViaGitExec( + (gitArgs) => gitExecFileAsync(gitArgs, { cwd: repoPath, ...gitOptions }), + ref + ) +} + +function getLocalGitHubPrForBranch( + repoPath: string, + branchName: string, + gitOptions: { wslDistro?: string } +): ReturnType { + return hasLocalGitOptions(gitOptions) + ? getPRForBranch(repoPath, branchName, null, null, null, { localGitExecOptions: gitOptions }) + : getPRForBranch(repoPath, branchName) +} + function hasRemoteCommitObject( provider: SshGitProvider, repoPath: string, @@ -768,11 +803,12 @@ export async function prepareWorktreePushTarget( repoPath: string, target: GitPushTarget, store?: WorktreePushTargetStore, - repoId?: string + repoId?: string, + gitOptions: { wslDistro?: string } = {} ): Promise { - await validateGitPushTarget(repoPath, target) + await validateGitPushTarget(repoPath, target, gitOptions) return prepareWorktreePushTargetWithExec( - (args, cwd) => gitExecFileAsync(args, { cwd }), + (args, cwd) => gitExecFileAsync(args, { cwd, ...gitOptions }), repoPath, target, (existingRemote) => @@ -813,7 +849,8 @@ export async function cleanupUnusedWorktreePushTargetRemote( repoPath: string, removedWorktreeId: string, target: GitPushTarget | undefined, - store: WorktreePushTargetStore + store: WorktreePushTargetStore, + gitOptions: { wslDistro?: string } = {} ): Promise { try { await cleanupUnusedWorktreePushTargetRemoteWithExec( @@ -821,7 +858,7 @@ export async function cleanupUnusedWorktreePushTargetRemote( removedWorktreeId, target, store, - (args, cwd) => gitExecFileAsync(args, { cwd }) + (args, cwd) => gitExecFileAsync(args, { cwd, ...gitOptions }) ) } catch (error) { console.warn(`[worktrees] Failed to clean up fork PR remote for ${removedWorktreeId}`, error) @@ -831,10 +868,11 @@ export async function cleanupUnusedWorktreePushTargetRemote( export async function configureCreatedWorktreePushTarget( worktreePath: string, branchName: string, - target: GitPushTarget + target: GitPushTarget, + gitOptions: { wslDistro?: string } = {} ): Promise { return configureCreatedWorktreePushTargetWithExec( - (args, cwd) => gitExecFileAsync(args, { cwd }), + (args, cwd) => gitExecFileAsync(args, { cwd, ...gitOptions }), worktreePath, branchName, target @@ -1719,6 +1757,18 @@ export async function createLocalWorktree( const timing = createWorktreeCreateTimingRecorder() const settings = store.getSettings() const worktreePathSettings = getWorktreePathSettings(repo, settings) + const localGitExecOptions = getLocalProjectGitExecOptions(store, repo) + const localWorktreeGitOptions = getLocalProjectWorktreeGitOptions(store, repo) + const hasLocalWorktreeGitOptions = Object.keys(localWorktreeGitOptions).length > 0 + const localWorktreeGitOptionArgs: [] | [{ wslDistro?: string }] = hasLocalWorktreeGitOptions + ? [localWorktreeGitOptions] + : [] + const addProjectGitOptions = (options?: AddWorktreeOptions): AddWorktreeOptions | undefined => { + if (!hasLocalWorktreeGitOptions) { + return options + } + return { ...options, ...localWorktreeGitOptions } + } const username = getGitUsername(repo.path) const requestedName = args.name @@ -1730,7 +1780,10 @@ export async function createLocalWorktree( // Why: resolve the base before branch/path selection so remote-tracking bases // can be refreshed before `git worktree add`. Creating first and repairing // later races setup scripts, agents, and user edits. - const baseBranch = args.baseBranch || repo.worktreeBaseRef || getDefaultBaseRef(repo.path) + const baseBranch = + args.baseBranch || + repo.worktreeBaseRef || + (await resolveDefaultBaseRefViaExec((argv) => gitExecFileAsync(argv, localGitExecOptions))) if (!baseBranch) { // Why: getDefaultBaseRef may return null when none of origin/HEAD, // origin/main, origin/master, local main, or local master exist. Don't @@ -1751,31 +1804,45 @@ export async function createLocalWorktree( let legacyFetchPromise: Promise | null = null if (runtime) { - remoteTrackingBase = await runtime.resolveRemoteTrackingBase(repo.path, baseBranch) + remoteTrackingBase = await runtime.resolveRemoteTrackingBase( + repo.path, + baseBranch, + ...localWorktreeGitOptionArgs + ) if (remoteTrackingBase) { - const hasLocalBaseRef = await runtime.hasRemoteTrackingRef(repo.path, remoteTrackingBase) + const hasLocalBaseRef = await runtime.hasRemoteTrackingRef( + repo.path, + remoteTrackingBase, + ...localWorktreeGitOptionArgs + ) emitCreateWorktreeProgress(mainWindow, 'fetching', args.creationId) remoteTrackingRefresh = { base: remoteTrackingBase, hadLocalBaseRef: hasLocalBaseRef, - promise: runtime.getOrStartRemoteTrackingBaseRefresh(repo.path, remoteTrackingBase) + promise: runtime.getOrStartRemoteTrackingBaseRefresh( + repo.path, + remoteTrackingBase, + ...localWorktreeGitOptionArgs + ) } - } else if (!(await hasLocalCommitObject(repo.path, baseBranch))) { + } else if ( + !(await hasLocalCommitObjectWithOptions(repo.path, baseBranch, localWorktreeGitOptions)) + ) { // Why: when the base branch does not match a configured remote prefix // (e.g. plain `main`, `master`, or any local branch), the legacy path // still ran a best-effort `git fetch origin`. Verified PR SHA bases // already have the needed commit object, so skip that broad fetch. const fallbackRemote = baseBranch.includes('/') ? baseBranch.split('/')[0] : 'origin' legacyFetchPromise = runtime - .fetchRemoteWithCache(repo.path, fallbackRemote) + .fetchRemoteWithCache(repo.path, fallbackRemote, ...localWorktreeGitOptionArgs) .then(() => undefined) .catch(() => undefined) emitCreateWorktreeProgress(mainWindow, 'fetching', args.creationId) } } else { - if (!(await hasLocalCommitObject(repo.path, baseBranch))) { + if (!(await hasLocalCommitObjectWithOptions(repo.path, baseBranch, localWorktreeGitOptions))) { const remote = baseBranch.includes('/') ? baseBranch.split('/')[0] : 'origin' - legacyFetchPromise = gitExecFileAsync(['fetch', remote], { cwd: repo.path }) + legacyFetchPromise = gitExecFileAsync(['fetch', remote], localGitExecOptions) .then(() => undefined) .catch(() => undefined) emitCreateWorktreeProgress(mainWindow, 'fetching', args.creationId) @@ -1852,9 +1919,15 @@ export async function createLocalWorktree( : undefined, effectiveSanitizedName, settings, - username + username, + localWorktreeGitOptions + ) + checkoutExistingBranch = await canCheckoutExistingLocalBranch( + repo.path, + branchName, + baseBranch, + localWorktreeGitOptions ) - checkoutExistingBranch = await canCheckoutExistingLocalBranch(repo.path, branchName, baseBranch) if (checkoutExistingBranch && !selectedExistingLocalBranchName) { // Why: suffix retries may need a new path, but an existing branch checkout // must keep using the user-selected branch instead of creating a sibling. @@ -1862,7 +1935,7 @@ export async function createLocalWorktree( } lastBranchConflictKind = checkoutExistingBranch ? null - : await getBranchConflictKind(repo.path, branchName, baseBranch) + : await getBranchConflictKind(repo.path, branchName, baseBranch, localWorktreeGitOptions) const allowedPushTargetRemoteConflict = lastBranchConflictKind && isAllowedPushTargetRemoteConflict(lastBranchConflictKind, branchName, args) @@ -1873,7 +1946,11 @@ export async function createLocalWorktree( const selectedReview = getSelectedReviewBranch(args) if (selectedReview?.provider === 'github') { try { - lastExistingPR = await getPRForBranch(repo.path, branchName) + lastExistingPR = await getLocalGitHubPrForBranch( + repo.path, + branchName, + localWorktreeGitOptions + ) } catch { lookupFailed = true } @@ -1918,7 +1995,11 @@ export async function createLocalWorktree( if (suffix > 1 && !checkoutExistingBranch) { lastExistingPR = null try { - lastExistingPR = await getPRForBranch(repo.path, branchName) + lastExistingPR = await getLocalGitHubPrForBranch( + repo.path, + branchName, + localWorktreeGitOptions + ) } catch { // GitHub API may be unreachable, rate-limited, or token missing } @@ -1978,7 +2059,11 @@ export async function createLocalWorktree( } if ( !remoteTrackingRefresh.hadLocalBaseRef && - !(await runtime?.hasRemoteTrackingRef(repo.path, remoteTrackingRefresh.base)) + !(await runtime?.hasRemoteTrackingRef( + repo.path, + remoteTrackingRefresh.base, + ...localWorktreeGitOptionArgs + )) ) { throw new Error(`Base ref "${baseBranch}" was not found after fetching.`) } @@ -1997,7 +2082,13 @@ export async function createLocalWorktree( // Why: validate and fetch the contributor remote before creating the // worktree. If this fails, retrying won't hit branch/path conflicts from a // half-created worktree. - preparedPushTarget = await prepareWorktreePushTarget(repo.path, args.pushTarget, store, repo.id) + preparedPushTarget = await prepareWorktreePushTarget( + repo.path, + args.pushTarget, + store, + repo.id, + localWorktreeGitOptions + ) } const suggestLocalBaseRefUpdate = @@ -2021,7 +2112,7 @@ export async function createLocalWorktree( sparseDirectories, baseBranch, settings.refreshLocalBaseRefOnWorktreeCreate, - existingBranchOption + addProjectGitOptions(existingBranchOption) ) } if (suggestLocalBaseRefUpdate) { @@ -2032,10 +2123,11 @@ export async function createLocalWorktree( sparseDirectories, baseBranch, settings.refreshLocalBaseRefOnWorktreeCreate, - { ...remoteTrackingBaseOption, suggestLocalBaseRefUpdate } + addProjectGitOptions({ ...remoteTrackingBaseOption, suggestLocalBaseRefUpdate }) ) } - return remoteTrackingBaseOption + const sparseOptions = addProjectGitOptions(remoteTrackingBaseOption) + return sparseOptions ? addSparseWorktree( repo.path, worktreePath, @@ -2043,7 +2135,7 @@ export async function createLocalWorktree( sparseDirectories, baseBranch, settings.refreshLocalBaseRefOnWorktreeCreate, - remoteTrackingBaseOption + sparseOptions ) : addSparseWorktree( repo.path, @@ -2063,7 +2155,7 @@ export async function createLocalWorktree( baseBranch, settings.refreshLocalBaseRefOnWorktreeCreate, false, - existingBranchOption + addProjectGitOptions(existingBranchOption) ) } if (suggestLocalBaseRefUpdate) { @@ -2074,10 +2166,11 @@ export async function createLocalWorktree( baseBranch, settings.refreshLocalBaseRefOnWorktreeCreate, false, - { ...remoteTrackingBaseOption, suggestLocalBaseRefUpdate } + addProjectGitOptions({ ...remoteTrackingBaseOption, suggestLocalBaseRefUpdate }) ) } - return remoteTrackingBaseOption + const worktreeOptions = addProjectGitOptions(remoteTrackingBaseOption) + return worktreeOptions ? addWorktree( repo.path, worktreePath, @@ -2085,7 +2178,7 @@ export async function createLocalWorktree( baseBranch, settings.refreshLocalBaseRefOnWorktreeCreate, false, - remoteTrackingBaseOption + worktreeOptions ) : addWorktree( repo.path, @@ -2105,13 +2198,16 @@ export async function createLocalWorktree( configuredPushTarget = await configureCreatedWorktreePushTarget( worktreePath, branchName, - preparedPushTarget + preparedPushTarget, + localWorktreeGitOptions ) } // Re-list to get the freshly created worktree info const gitWorktrees = await timing.time('list_created_worktree', async () => - listWorktrees(repo.path) + hasLocalWorktreeGitOptions + ? listWorktrees(repo.path, localWorktreeGitOptions) + : listWorktrees(repo.path) ) const created = gitWorktrees.find((gw) => areWorktreePathsEqual(gw.path, worktreePath)) if (!created) { @@ -2252,7 +2348,12 @@ export async function createLocalWorktree( // fails, surfacing the error as a hard create failure would lie to the UI // about the underlying git state and strand a real worktree on disk. // Degrade to "created without setup launch" instead. - setup = createSetupRunnerScript(repo, worktreePath, setupScript) + setup = createSetupRunnerScript( + repo, + worktreePath, + setupScript, + ...localWorktreeGitOptionArgs + ) } catch (error) { console.error(`[hooks] Failed to prepare setup runner for ${worktreePath}:`, error) } diff --git a/src/main/ipc/worktrees-windows.test.ts b/src/main/ipc/worktrees-windows.test.ts index 53f77290ea1..0fa83427115 100644 --- a/src/main/ipc/worktrees-windows.test.ts +++ b/src/main/ipc/worktrees-windows.test.ts @@ -8,6 +8,7 @@ const { removeWorktreeMock, getGitUsernameMock, getDefaultBaseRefMock, + resolveDefaultBaseRefViaExecMock, getBranchConflictKindMock, getPRForBranchMock, createGitHubPullRequestMock, @@ -30,6 +31,7 @@ const { removeWorktreeMock: vi.fn(), getGitUsernameMock: vi.fn(), getDefaultBaseRefMock: vi.fn(), + resolveDefaultBaseRefViaExecMock: vi.fn(), getBranchConflictKindMock: vi.fn(), getPRForBranchMock: vi.fn(), createGitHubPullRequestMock: vi.fn(), @@ -70,6 +72,7 @@ vi.mock('../git/runner', () => ({ vi.mock('../git/repo', () => ({ getGitUsername: getGitUsernameMock, getDefaultBaseRef: getDefaultBaseRefMock, + resolveDefaultBaseRefViaExec: resolveDefaultBaseRefViaExecMock, getBranchConflictKind: getBranchConflictKindMock })) @@ -114,6 +117,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => { const store = { getRepos: vi.fn(), getRepo: vi.fn(), + getProjects: vi.fn(), getProjectHostSetups: vi.fn(), getSettings: vi.fn(), getWorktreeMeta: vi.fn(), @@ -129,6 +133,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => { removeWorktreeMock.mockReset() getGitUsernameMock.mockReset() getDefaultBaseRefMock.mockReset() + resolveDefaultBaseRefViaExecMock.mockReset() getBranchConflictKindMock.mockReset() getPRForBranchMock.mockReset() createGitHubPullRequestMock.mockReset() @@ -146,6 +151,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => { mainWindow.webContents.send.mockReset() store.getRepos.mockReset() store.getRepo.mockReset() + store.getProjects.mockReset() store.getProjectHostSetups.mockReset() store.getSettings.mockReset() store.getWorktreeMeta.mockReset() @@ -177,6 +183,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => { addedAt: 0, worktreeBaseRef: null }) + store.getProjects.mockReturnValue([]) store.getProjectHostSetups.mockReturnValue([]) store.getSettings.mockReturnValue({ branchPrefix: 'none', @@ -188,6 +195,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => { store.setWorktreeMeta.mockReturnValue({}) getGitUsernameMock.mockReturnValue('') getDefaultBaseRefMock.mockReturnValue('origin/main') + resolveDefaultBaseRefViaExecMock.mockResolvedValue('origin/main') getBranchConflictKindMock.mockResolvedValue(null) getPRForBranchMock.mockResolvedValue(null) getEffectiveHooksMock.mockReturnValue(null) diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 688d42a70a2..f981d3eeef4 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -2,9 +2,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { lstat, mkdir, mkdtemp, rm, writeFile } from 'fs/promises' import { tmpdir } from 'os' -import { join } from 'path' +import { join, resolve } from 'path' import type { CreateWorktreeResult } from '../../shared/types' +const ORIGINAL_PLATFORM = process.platform + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { + configurable: true, + value: platform + }) +} + const { handleMock, removeHandlerMock, @@ -17,6 +26,7 @@ const { forceDeleteLocalBranchMock, getGitUsernameMock, getDefaultBaseRefMock, + resolveDefaultBaseRefViaExecMock, getDefaultRemoteMock, getBranchConflictKindMock, getPRForBranchMock, @@ -65,6 +75,7 @@ const { forceDeleteLocalBranchMock: vi.fn(), getGitUsernameMock: vi.fn(), getDefaultBaseRefMock: vi.fn(), + resolveDefaultBaseRefViaExecMock: vi.fn(), getDefaultRemoteMock: vi.fn(), getBranchConflictKindMock: vi.fn(), getPRForBranchMock: vi.fn(), @@ -117,6 +128,7 @@ vi.mock('../git/runner', () => ({ vi.mock('../git/repo', () => ({ getGitUsername: getGitUsernameMock, getDefaultBaseRef: getDefaultBaseRefMock, + resolveDefaultBaseRefViaExec: resolveDefaultBaseRefViaExecMock, getDefaultRemote: getDefaultRemoteMock, getBranchConflictKind: getBranchConflictKindMock })) @@ -230,6 +242,7 @@ describe('registerWorktreeHandlers', () => { const store = { getRepos: vi.fn(), getRepo: vi.fn(), + getProjects: vi.fn(), getSparsePresets: vi.fn(), getSettings: vi.fn(), getWorktreeMeta: vi.fn(), @@ -256,6 +269,7 @@ describe('registerWorktreeHandlers', () => { } beforeEach(() => { + setPlatform(ORIGINAL_PLATFORM) __resetSshWorktreeCreateFetchCacheForTests() invalidateAuthorizedRootsCache() for (const m of [ @@ -269,6 +283,7 @@ describe('registerWorktreeHandlers', () => { forceDeleteLocalBranchMock, getGitUsernameMock, getDefaultBaseRefMock, + resolveDefaultBaseRefViaExecMock, getDefaultRemoteMock, getBranchConflictKindMock, getPRForBranchMock, @@ -297,6 +312,7 @@ describe('registerWorktreeHandlers', () => { mainWindow.webContents.send, store.getRepos, store.getRepo, + store.getProjects, store.getSparsePresets, store.getSettings, store.getWorktreeMeta, @@ -339,6 +355,7 @@ describe('registerWorktreeHandlers', () => { } store.getRepos.mockReturnValue([repo]) store.getRepo.mockReturnValue({ ...repo, worktreeBaseRef: null }) + store.getProjects.mockReturnValue([]) store.getSparsePresets.mockReturnValue([]) store.getSettings.mockReturnValue({ branchPrefix: 'none', @@ -366,6 +383,7 @@ describe('registerWorktreeHandlers', () => { store.getAllWorktreeLineage.mockReturnValue({}) getGitUsernameMock.mockReturnValue('') getDefaultBaseRefMock.mockReturnValue('origin/main') + resolveDefaultBaseRefViaExecMock.mockResolvedValue('origin/main') getDefaultRemoteMock.mockResolvedValue('origin') getBranchConflictKindMock.mockResolvedValue(null) getPRForBranchMock.mockResolvedValue(null) @@ -615,6 +633,21 @@ describe('registerWorktreeHandlers', () => { } } + function mockSelectedWslProjectRuntime(): void { + setPlatform('win32') + store.getProjects.mockReturnValue([ + { + id: 'project-1', + displayName: 'repo', + badgeColor: '#000', + sourceRepoIds: ['repo-1'], + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }, + createdAt: 0, + updatedAt: 0 + } + ]) + } + it('strips Orca provenance fields from renderer metadata updates', () => { store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) @@ -742,7 +775,7 @@ describe('registerWorktreeHandlers', () => { const listWorktreesCallsAfterCreate = listWorktreesMock.mock.calls.length await expect( resolveRegisteredWorktreePath('/workspace/improve-dashboard', store as never) - ).resolves.toBe('/workspace/improve-dashboard') + ).resolves.toBe(resolve('/workspace/improve-dashboard')) expect(listWorktreesMock).toHaveBeenCalledTimes(listWorktreesCallsAfterCreate) }) @@ -1673,6 +1706,235 @@ describe('registerWorktreeHandlers', () => { }) }) + it('routes local worktree creation through the selected WSL project runtime', async () => { + mockSelectedWslProjectRuntime() + resolveDefaultBaseRefViaExecMock.mockImplementation( + async (exec: (args: string[]) => Promise<{ stdout: string }>) => { + await exec(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD']) + return 'origin/main' + } + ) + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/repo', + head: 'base', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + }, + { + path: '/workspace/improve-dashboard', + head: 'abc123', + branch: 'refs/heads/improve-dashboard', + isBare: false, + isMainWorktree: false + } + ]) + + await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'improve-dashboard' + }) + + expect(addWorktreeMock).toHaveBeenCalledWith( + '/workspace/repo', + '/workspace/improve-dashboard', + 'improve-dashboard', + 'origin/main', + false, + false, + { wslDistro: 'Ubuntu' } + ) + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], + { cwd: '/workspace/repo', wslDistro: 'Ubuntu' } + ) + expect(getBranchConflictKindMock).toHaveBeenCalledWith( + '/workspace/repo', + 'improve-dashboard', + 'origin/main', + { wslDistro: 'Ubuntu' } + ) + expect(listWorktreesMock).toHaveBeenCalledWith('/workspace/repo', { wslDistro: 'Ubuntu' }) + }) + + it('routes fork push target setup through the selected WSL project runtime', async () => { + mockSelectedWslProjectRuntime() + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/wsl-fork', + head: 'abc123', + branch: 'refs/heads/wsl-fork', + isBare: false, + isMainWorktree: false + } + ]) + store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + + await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'wsl-fork', + pushTarget: { + remoteName: 'pr-contributor-orca', + branchName: 'contributor/wsl-fork', + remoteUrl: 'git@github.com:contributor/orca.git' + } + }) + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['check-ref-format', '--branch', 'contributor/wsl-fork'], + { cwd: '/workspace/repo', wslDistro: 'Ubuntu' } + ) + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['remote', 'add', 'pr-contributor-orca', 'git@github.com:contributor/orca.git'], + { cwd: '/workspace/repo', wslDistro: 'Ubuntu' } + ) + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + [ + 'fetch', + 'pr-contributor-orca', + '+refs/heads/contributor/wsl-fork:refs/remotes/pr-contributor-orca/contributor/wsl-fork' + ], + { cwd: '/workspace/repo', wslDistro: 'Ubuntu' } + ) + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['branch', '--set-upstream-to', 'pr-contributor-orca/contributor/wsl-fork', 'wsl-fork'], + { cwd: '/workspace/wsl-fork', wslDistro: 'Ubuntu' } + ) + }) + + it('routes selected PR branch conflict lookup through the selected WSL project runtime', async () => { + mockSelectedWslProjectRuntime() + getBranchConflictKindMock.mockResolvedValueOnce('remote') + getPRForBranchMock.mockResolvedValueOnce({ + number: 42, + title: 'Selected PR', + state: 'open', + url: 'https://example.com/pr/42', + checksStatus: 'success', + updatedAt: '2026-06-16T00:00:00.000Z', + mergeable: 'UNKNOWN' + }) + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/fix-title', + head: 'abc123', + branch: 'refs/heads/feature/fix', + isBare: false, + isMainWorktree: false + } + ]) + + await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'fix-title', + baseBranch: 'abc123', + branchNameOverride: 'feature/fix', + linkedPR: 42, + pushTarget: { remoteName: 'origin', branchName: 'feature/fix' } + }) + + expect(getPRForBranchMock).toHaveBeenCalledWith( + '/workspace/repo', + 'feature/fix', + null, + null, + null, + { localGitExecOptions: { wslDistro: 'Ubuntu' } } + ) + expect(addWorktreeMock).toHaveBeenCalledWith( + '/workspace/repo', + '/workspace/fix-title', + 'feature/fix', + 'abc123', + false, + false, + { wslDistro: 'Ubuntu' } + ) + }) + + it('routes PR base git calls through the selected WSL project runtime', async () => { + setPlatform('win32') + store.getProjects.mockReturnValue([ + { + id: 'project-1', + displayName: 'repo', + badgeColor: '#000', + sourceRepoIds: ['repo-1'], + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }, + createdAt: 0, + updatedAt: 0 + } + ]) + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'rev-parse') { + return { stdout: 'def456\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + + const result = await handlers['worktrees:resolvePrBase'](null, { + repoId: 'repo-1', + prNumber: 42, + headRefName: 'feature/add-feature', + isCrossRepository: false + }) + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + [ + 'fetch', + 'origin', + '+refs/heads/feature/add-feature:refs/remotes/origin/feature/add-feature' + ], + { cwd: '/workspace/repo', wslDistro: 'Ubuntu' } + ) + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['rev-parse', '--verify', 'origin/feature/add-feature'], + { cwd: '/workspace/repo', wslDistro: 'Ubuntu' } + ) + expect(getDefaultRemoteMock).toHaveBeenCalledWith('/workspace/repo', { wslDistro: 'Ubuntu' }) + expect(result).toMatchObject({ + baseBranch: 'def456', + headSha: 'def456', + branchNameOverride: 'feature/add-feature', + pushTarget: { remoteName: 'origin', branchName: 'feature/add-feature' } + }) + }) + + it('lists detected worktrees through the selected WSL project runtime', async () => { + setPlatform('win32') + store.getProjects.mockReturnValue([ + { + id: 'project-1', + displayName: 'repo', + badgeColor: '#000', + sourceRepoIds: ['repo-1'], + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }, + createdAt: 0, + updatedAt: 0 + } + ]) + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/repo', + head: 'def456', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + + const result = await handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }) + + expect(listWorktreesMock).toHaveBeenCalledWith('/workspace/repo', { wslDistro: 'Ubuntu' }) + expect(result).toMatchObject({ + repoId: 'repo-1', + authoritative: true, + source: 'git', + worktrees: [expect.objectContaining({ path: '/workspace/repo' })] + }) + }) + it('fetches the same-repo PR head via the SSH tracking-ref RPC, not git.exec', async () => { const fetchRemoteTrackingRef = vi.fn(async () => {}) const exec = vi.fn(async (args: string[]) => { @@ -3391,11 +3653,11 @@ describe('registerWorktreeHandlers', () => { it('throws a clear error when no default base ref can be resolved', async () => { // Why: guard against regressing to a silent 'origin/main' fallback. When - // getDefaultBaseRef returns null (e.g. a fresh repo with no origin/HEAD, + // default-base resolution returns null (e.g. a fresh repo with no origin/HEAD, // no origin/main, no origin/master, and no local main/master), we must // fail loudly with a message that prompts the user to pick a base // branch, not hand a non-existent ref to `git worktree add`. - getDefaultBaseRefMock.mockReturnValue(null) + resolveDefaultBaseRefViaExecMock.mockResolvedValue(null) store.getRepo.mockReturnValue({ id: 'repo-1', path: '/workspace/repo', @@ -3424,7 +3686,8 @@ describe('registerWorktreeHandlers', () => { expect(createIssueCommandRunnerScriptMock).toHaveBeenCalledWith( expect.objectContaining({ id: 'repo-1' }), '/workspace/improve-dashboard', - 'codex exec "long command"' + 'codex exec "long command"', + {} ) expect(result).toMatchObject({ runnerScriptPath: '/workspace/repo/.git/orca/issue-command-runner.sh', @@ -4395,6 +4658,55 @@ describe('registerWorktreeHandlers', () => { ) }) + it('routes setup runner generation through the selected WSL project runtime', async () => { + setPlatform('win32') + store.getProjects.mockReturnValue([ + { + id: 'project-1', + displayName: 'repo', + badgeColor: '#000', + sourceRepoIds: ['repo-1'], + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }, + createdAt: 0, + updatedAt: 0 + } + ]) + listWorktreesMock.mockResolvedValue(createdWorktreeList) + getEffectiveHooksMock.mockReturnValue({ + scripts: { + setup: 'pnpm worktree:setup' + } + }) + getEffectiveHooksFromConfigMock.mockReturnValue({ + scripts: { + setup: 'pnpm worktree:setup' + } + }) + shouldRunSetupForCreateMock.mockReturnValue(true) + + await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'improve-dashboard', + setupDecision: 'run' + }) + + expect(createSetupRunnerScriptMock).toHaveBeenCalledWith( + expect.objectContaining({ id: 'repo-1' }), + '/workspace/improve-dashboard', + 'pnpm worktree:setup', + { wslDistro: 'Ubuntu' } + ) + expect(addWorktreeMock).toHaveBeenCalledWith( + '/workspace/repo', + '/workspace/improve-dashboard', + 'improve-dashboard', + 'origin/main', + false, + false, + { wslDistro: 'Ubuntu' } + ) + }) + it('launches setup even when primary and worktree orca.yaml scripts diverge', async () => { // Why: regression for a silent skip introduced by the #1280 content-equality // gate. Benign divergence (whitespace, comments, or any setup edit that @@ -4722,7 +5034,9 @@ describe('registerWorktreeHandlers', () => { expect(runHookMock).toHaveBeenCalledWith( 'archive', '/workspace/feature-wt', - expect.objectContaining({ id: 'repo-1' }) + expect.objectContaining({ id: 'repo-1' }), + undefined, + {} ) expect(removeWorktreeMock).toHaveBeenCalledWith( '/workspace/repo', @@ -5801,6 +6115,28 @@ describe('registerWorktreeHandlers', () => { expect(callOrder).toEqual(['preflight', 'kill', 'git']) }) + it('routes local worktree removal through the selected WSL project runtime', async () => { + mockSelectedWslProjectRuntime() + mockKnownFeatureWorktree() + getEffectiveHooksMock.mockReturnValue(null) + removeWorktreeMock.mockResolvedValue({}) + + await handlers['worktrees:remove'](null, { + worktreeId: 'repo-1::/workspace/feature-wt' + }) + + expect(listWorktreesMock).toHaveBeenCalledWith('/workspace/repo', { wslDistro: 'Ubuntu' }) + expect(assertWorktreeCleanForRemovalMock).toHaveBeenCalledWith('/workspace/feature-wt', false, { + wslDistro: 'Ubuntu' + }) + expect(removeWorktreeMock).toHaveBeenCalledWith( + '/workspace/repo', + '/workspace/feature-wt', + false, + { wslDistro: 'Ubuntu' } + ) + }) + it('fails dirty non-force deletes before PTY teardown', async () => { mockKnownFeatureWorktree() getEffectiveHooksMock.mockReturnValue(null) diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 4b70ae73e24..666912f251b 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -1,7 +1,7 @@ /* oxlint-disable max-lines */ import type { BrowserWindow } from 'electron' import { ipcMain } from 'electron' -import { readFile, rm, stat } from 'fs/promises' +import { readFile, stat } from 'fs/promises' import { randomUUID } from 'crypto' import type { Store } from '../persistence' import { isFolderRepo } from '../../shared/repo-kind' @@ -105,6 +105,15 @@ import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses' import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../shared/worktree-id' import { prefetchWorktreeCreateBase } from '../worktree-create-base-prefetch' +import { + getLocalProjectGitExecOptions, + getLocalProjectWorktreeGitOptions +} from '../project-runtime-git-options' +import { + getLocalWorktreePathAccess, + removeLocalWorktreePath, + toLocalWorktreeRuntimePath +} from '../local-worktree-filesystem' const WORKTREE_ARCHIVE_HOOK_TIMEOUT_MS = 120_000 const WORKTREE_LIST_ALL_CONCURRENCY = 8 @@ -209,9 +218,17 @@ function resolveWorktreeMetaWithDiscoveryBackfill( }) } -async function isAlreadyRemovedWorktreePath(repo: Repo, worktreePath: string): Promise { +async function isAlreadyRemovedWorktreePath( + repo: Repo, + worktreePath: string, + localWorktreeGitOptions: { wslDistro?: string } = {} +): Promise { if (!repo.connectionId) { - return isWorktreePathMissing(worktreePath) + const access = getLocalWorktreePathAccess(localWorktreeGitOptions) + return isWorktreePathMissing( + toLocalWorktreeRuntimePath(worktreePath, localWorktreeGitOptions), + access.statPath + ) } const fsProvider = getSshFilesystemProvider(repo.connectionId) @@ -807,7 +824,10 @@ export function registerWorktreeHandlers( return listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex) } } else { - gitWorktrees = await listRepoWorktrees(repo) + gitWorktrees = await listRepoWorktrees( + repo, + getLocalProjectWorktreeGitOptions(store, repo) + ) } rememberLocalWorktreeRoots(store, repo, gitWorktrees) pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees) @@ -872,7 +892,7 @@ export function registerWorktreeHandlers( return listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex) } } else { - gitWorktrees = await listRepoWorktrees(repo) + gitWorktrees = await listRepoWorktrees(repo, getLocalProjectWorktreeGitOptions(store, repo)) } rememberLocalWorktreeRoots(store, repo, gitWorktrees) pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees) @@ -931,7 +951,10 @@ export function registerWorktreeHandlers( } gitWorktrees = await provider.listWorktrees(repo.path) } else { - gitWorktrees = await listRepoWorktrees(repo) + gitWorktrees = await listRepoWorktrees( + repo, + getLocalProjectWorktreeGitOptions(store, repo) + ) } rememberLocalWorktreeRoots(store, repo, gitWorktrees) pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees) @@ -1067,7 +1090,7 @@ export function registerWorktreeHandlers( } const gitExec = async (args: string[]): Promise<{ stdout: string; stderr: string }> => { if (!repo.connectionId) { - return gitExecFileAsync(args, { cwd: repo.path }) + return gitExecFileAsync(args, getLocalProjectGitExecOptions(store, repo)) } const provider = getSshGitProvider(repo.connectionId) if (!provider) { @@ -1084,7 +1107,8 @@ export function registerWorktreeHandlers( repo, repo.connectionId ? getSshGitProvider(repo.connectionId) : undefined, remote, - branch + branch, + { localGitExecOptions: getLocalProjectGitExecOptions(store, repo) } ) return resolveGitHubPrStartPoint({ @@ -1094,6 +1118,7 @@ export function registerWorktreeHandlers( baseRefName: args.baseRefName, isCrossRepository: args.isCrossRepository, connectionId: repo.connectionId ?? null, + localGitOptions: getLocalProjectWorktreeGitOptions(store, repo), gitExec, fetchRemoteTrackingRef, resolveRemote: async () => { @@ -1106,7 +1131,7 @@ export function registerWorktreeHandlers( .find(Boolean) ?? 'origin' ) } - return getDefaultRemote(repo.path) + return getDefaultRemote(repo.path, getLocalProjectWorktreeGitOptions(store, repo)) } }) } @@ -1186,9 +1211,15 @@ export function registerWorktreeHandlers( // Why: the renderer-supplied worktreeId contains a filesystem path. // Re-derive the canonical path from git before any destructive action. const provider = repo.connectionId ? requireSshGitProvider(repo.connectionId) : null + const localWorktreeGitOptions = repo.connectionId + ? {} + : getLocalProjectWorktreeGitOptions(store, repo) + const hasLocalWorktreeGitOptions = Object.keys(localWorktreeGitOptions).length > 0 const registeredWorktrees = repo.connectionId ? await provider!.listWorktrees(repo.path) - : await listGitWorktrees(repo.path) + : hasLocalWorktreeGitOptions + ? await listGitWorktrees(repo.path, localWorktreeGitOptions) + : await listGitWorktrees(repo.path) const removedMeta = store.getWorktreeMeta(args.worktreeId) const removedPushTarget = removedMeta?.pushTarget const registeredWorktree = findRegisteredDeletableWorktree( @@ -1222,14 +1253,20 @@ export function registerWorktreeHandlers( (path) => fsProvider.readFile(path) ) } else { + const access = getLocalWorktreePathAccess(localWorktreeGitOptions) canCleanOrphanedDirectory = await canSafelyRemoveOrphanedWorktreeDirectory( - worktreePath, - repo.path + toLocalWorktreeRuntimePath(worktreePath, localWorktreeGitOptions), + toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), + access.statPath, + access.readPath ) } } if (canCleanOrphanedDirectory) { - assertWorktreeDoesNotContainRegisteredWorktree(worktreePath, registeredWorktrees) + assertWorktreeDoesNotContainRegisteredWorktree( + toLocalWorktreeRuntimePath(worktreePath, localWorktreeGitOptions), + registeredWorktrees + ) if (!args.force) { throw new Error(ORPHANED_WORKTREE_DIRECTORY_MESSAGE) } @@ -1244,12 +1281,13 @@ export function registerWorktreeHandlers( ) } else { await closeLocalWatcherForRemoval(worktreePath) - await rm(worktreePath, { recursive: true, force: true }) + await removeLocalWorktreePath(worktreePath, localWorktreeGitOptions) await cleanupUnusedWorktreePushTargetRemote( repo.path, args.worktreeId, removedPushTarget, - store + store, + localWorktreeGitOptions ) invalidateAuthorizedRootsCache() } @@ -1259,7 +1297,7 @@ export function registerWorktreeHandlers( notifyWorktreesChanged(mainWindow, repoId) return {} } - if (await isAlreadyRemovedWorktreePath(repo, worktreePath)) { + if (await isAlreadyRemovedWorktreePath(repo, worktreePath, localWorktreeGitOptions)) { if (!args.force && !removedMeta) { // Why: without persisted metadata, require the renderer recovery // path before deleting Orca-only state for an unregistered path. @@ -1281,7 +1319,8 @@ export function registerWorktreeHandlers( repo.path, args.worktreeId, removedPushTarget, - store + store, + localWorktreeGitOptions ) invalidateAuthorizedRootsCache() } @@ -1303,7 +1342,13 @@ export function registerWorktreeHandlers( if (hooks?.scripts.archive && !args.skipArchive) { const result = repo.connectionId ? await runRemoteArchiveHook(repo, canonicalWorktreePath, hooks.scripts.archive) - : await runHook('archive', canonicalWorktreePath, repo) + : await runHook( + 'archive', + canonicalWorktreePath, + repo, + undefined, + localWorktreeGitOptions + ) if (!result.success) { console.error( `[hooks] archive hook failed for ${canonicalWorktreePath}:`, @@ -1360,7 +1405,13 @@ export function registerWorktreeHandlers( } try { - await assertWorktreeCleanForRemoval(canonicalWorktreePath, args.force ?? false) + await (hasLocalWorktreeGitOptions + ? assertWorktreeCleanForRemoval( + canonicalWorktreePath, + args.force ?? false, + localWorktreeGitOptions + ) + : assertWorktreeCleanForRemoval(canonicalWorktreePath, args.force ?? false)) } catch (error) { if (!isOrphanCompatiblePreflightError(error)) { throw new Error( @@ -1397,18 +1448,21 @@ export function registerWorktreeHandlers( let removalResult: RemoveWorktreeResult | undefined try { + const removeOptions = { + ...(!deleteBranch ? { deleteBranch } : {}), + // Why: this handler already paid for an authoritative worktree + // list to validate the target; reuse it instead of rescanning + // every sibling worktree during the hot delete path. + knownRemovedWorktree: registeredWorktree, + ...(hasLocalWorktreeGitOptions ? localWorktreeGitOptions : {}) + } removalResult = preserveBranchHeadFallback( - await (deleteBranch - ? removeWorktree(repo.path, canonicalWorktreePath, args.force ?? false, { - // Why: this handler already paid for an authoritative worktree - // list to validate the target; reuse it instead of rescanning - // every sibling worktree during the hot delete path. - knownRemovedWorktree: registeredWorktree - }) - : removeWorktree(repo.path, canonicalWorktreePath, args.force ?? false, { - deleteBranch, - knownRemovedWorktree: registeredWorktree - })), + await removeWorktree( + repo.path, + canonicalWorktreePath, + args.force ?? false, + removeOptions + ), registeredWorktree.head ) } catch (error) { @@ -1417,9 +1471,19 @@ export function registerWorktreeHandlers( console.warn( `[worktrees] Orphaned worktree detected at ${canonicalWorktreePath}, cleaning up` ) - if (await canSafelyRemoveOrphanedWorktreeDirectory(canonicalWorktreePath, repo.path)) { + const access = getLocalWorktreePathAccess(localWorktreeGitOptions) + if ( + await canSafelyRemoveOrphanedWorktreeDirectory( + toLocalWorktreeRuntimePath(canonicalWorktreePath, localWorktreeGitOptions), + toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), + access.statPath, + access.readPath + ) + ) { await closeLocalWatcherForRemoval(canonicalWorktreePath) - await rm(canonicalWorktreePath, { recursive: true, force: true }).catch(() => {}) + await removeLocalWorktreePath(canonicalWorktreePath, localWorktreeGitOptions).catch( + () => {} + ) } else { console.warn( `[worktrees] Refusing recursive cleanup for unproven worktree directory: ${canonicalWorktreePath}` @@ -1429,12 +1493,16 @@ export function registerWorktreeHandlers( // (`.git/worktrees/`) is still intact. Without pruning, `git worktree // list` continues to show the stale entry and the branch it had checked out // remains locked — other worktrees cannot check it out. - await gitExecFileAsync(['worktree', 'prune'], { cwd: repo.path }).catch(() => {}) + await gitExecFileAsync( + ['worktree', 'prune'], + getLocalProjectGitExecOptions(store, repo) + ).catch(() => {}) await cleanupUnusedWorktreePushTargetRemote( repo.path, args.worktreeId, removedPushTarget, - store + store, + localWorktreeGitOptions ) runtime.clearOptimisticReconcileToken(args.worktreeId) removeWorktreeMetadataAndTransientState(store, args.worktreeId) @@ -1451,7 +1519,8 @@ export function registerWorktreeHandlers( repo.path, args.worktreeId, removedPushTarget, - store + store, + localWorktreeGitOptions ) rememberPreservedBranchCleanupTarget( args.worktreeId, @@ -1513,12 +1582,22 @@ export function registerWorktreeHandlers( store ) } else { - await forceDeleteLocalBranch(repo.path, cleanupTarget.branchName, cleanupTarget.head) + const localWorktreeGitOptions = getLocalProjectWorktreeGitOptions(store, repo) + const hasLocalWorktreeGitOptions = Object.keys(localWorktreeGitOptions).length > 0 + await (hasLocalWorktreeGitOptions + ? forceDeleteLocalBranch( + repo.path, + cleanupTarget.branchName, + cleanupTarget.head, + (argv, cwd) => gitExecFileAsync(argv, { cwd, ...localWorktreeGitOptions }) + ) + : forceDeleteLocalBranch(repo.path, cleanupTarget.branchName, cleanupTarget.head)) await cleanupUnusedWorktreePushTargetRemote( repo.path, args.worktreeId, cleanupTarget.pushTarget, - store + store, + localWorktreeGitOptions ) } @@ -1644,7 +1723,12 @@ export function registerWorktreeHandlers( throw new Error(`Repo not found: ${args.repoId}`) } - return createIssueCommandRunnerScript(repo, args.worktreePath, args.command) + return createIssueCommandRunnerScript( + repo, + args.worktreePath, + args.command, + getLocalProjectWorktreeGitOptions(store, repo) + ) } ) diff --git a/src/main/local-project-runtime-resolution.ts b/src/main/local-project-runtime-resolution.ts new file mode 100644 index 00000000000..3cad91882f6 --- /dev/null +++ b/src/main/local-project-runtime-resolution.ts @@ -0,0 +1,64 @@ +import type { Store } from './persistence' +import type { Repo } from '../shared/types' +import { + resolveProjectExecutionRuntime, + type ProjectExecutionRuntimeResolution +} from '../shared/project-execution-runtime' +import { + getCachedWslAvailability, + getCachedWslDistros, + hasCachedWslAvailability, + hasCachedWslDistros +} from './wsl' +import { getRepoIdFromWorktreeId } from '../shared/worktree-id' +import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../shared/execution-host' + +function canResolveProjectRuntimeForRepo(store: Store): boolean { + return typeof store.getProjects === 'function' && typeof store.getSettings === 'function' +} + +function canResolveProjectRuntimeForWorktreeId(store: Store): boolean { + return canResolveProjectRuntimeForRepo(store) && typeof store.getRepo === 'function' +} + +export function resolveLocalProjectRuntimeForRepo( + store: Store, + repo: Repo +): ProjectExecutionRuntimeResolution | undefined { + if ( + getRepoExecutionHostId(repo) !== LOCAL_EXECUTION_HOST_ID || + !canResolveProjectRuntimeForRepo(store) + ) { + return undefined + } + const project = store.getProjects().find((entry) => entry.sourceRepoIds.includes(repo.id)) + if (!project) { + return undefined + } + const wslAvailable = hasCachedWslAvailability() + ? (getCachedWslAvailability() ?? undefined) + : undefined + const availableWslDistros = hasCachedWslDistros() ? getCachedWslDistros() : null + return resolveProjectExecutionRuntime({ + appPlatform: process.platform, + projectId: project.id, + projectRuntimePreference: project.localWindowsRuntimePreference, + globalWindowsRuntimeDefault: store.getSettings().localWindowsRuntimeDefault, + wslAvailable, + availableWslDistros + }) +} + +export function resolveLocalProjectRuntimeForWorktreeId( + store: Store | undefined, + worktreeId: string | undefined +): ProjectExecutionRuntimeResolution | undefined { + if (!store || !worktreeId) { + return undefined + } + if (!canResolveProjectRuntimeForWorktreeId(store)) { + return undefined + } + const repo = store.getRepo(getRepoIdFromWorktreeId(worktreeId)) + return repo ? resolveLocalProjectRuntimeForRepo(store, repo) : undefined +} diff --git a/src/main/local-worktree-filesystem.test.ts b/src/main/local-worktree-filesystem.test.ts new file mode 100644 index 00000000000..7c5400f5811 --- /dev/null +++ b/src/main/local-worktree-filesystem.test.ts @@ -0,0 +1,96 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { execFileMock, lstatMock, readFileMock, rmMock } = vi.hoisted(() => ({ + execFileMock: vi.fn(), + lstatMock: vi.fn(), + readFileMock: vi.fn(), + rmMock: vi.fn() +})) + +vi.mock('node:child_process', () => ({ + execFile: execFileMock +})) + +vi.mock('node:fs/promises', () => ({ + lstat: lstatMock, + readFile: readFileMock, + rm: rmMock +})) + +import { getLocalWorktreePathAccess, removeLocalWorktreePath } from './local-worktree-filesystem' + +function completeExecFile(stdout = ''): void { + execFileMock.mockImplementation((_file, _args, _options, callback) => { + callback(null, stdout, '') + }) +} + +async function withPlatform(platform: NodeJS.Platform, fn: () => Promise): Promise { + const original = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) + try { + return await fn() + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: original }) + } +} + +describe('local worktree filesystem runtime access', () => { + beforeEach(() => { + execFileMock.mockReset() + lstatMock.mockReset() + readFileMock.mockReset() + rmMock.mockReset() + completeExecFile() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('uses host filesystem operations when no WSL distro is selected', async () => { + lstatMock.mockResolvedValue({ type: 'file' }) + readFileMock.mockResolvedValue('gitdir: ../.git/worktrees/feature') + + const access = getLocalWorktreePathAccess() + await access.statPath('C:\\repo\\.git') + await access.readPath('C:\\repo\\.git') + await removeLocalWorktreePath('C:\\repo\\feature') + + expect(lstatMock).toHaveBeenCalledWith('C:\\repo\\.git') + expect(readFileMock).toHaveBeenCalledWith('C:\\repo\\.git', 'utf8') + expect(rmMock).toHaveBeenCalledWith('C:\\repo\\feature', { recursive: true, force: true }) + expect(execFileMock).not.toHaveBeenCalled() + }) + + it('uses the selected WSL distro for stat, read, and removal on Windows', async () => { + await withPlatform('win32', async () => { + completeExecFile('file') + const access = getLocalWorktreePathAccess({ wslDistro: 'Ubuntu' }) + await expect(access.statPath('/home/me/repo/.git')).resolves.toEqual({ type: 'file' }) + + completeExecFile('gitdir: /home/me/repo/.git/worktrees/feature\n') + await expect(access.readPath('/home/me/repo/.git')).resolves.toBe( + 'gitdir: /home/me/repo/.git/worktrees/feature\n' + ) + + completeExecFile() + await removeLocalWorktreePath('C:\\Users\\me\\repo feature', { wslDistro: 'Ubuntu' }) + + expect(execFileMock).toHaveBeenCalledTimes(3) + expect(execFileMock).toHaveBeenNthCalledWith( + 1, + 'wsl.exe', + expect.arrayContaining(['-d', 'Ubuntu']), + expect.objectContaining({ encoding: 'utf8' }), + expect.any(Function) + ) + const removeArgs = execFileMock.mock.calls[2]?.[1] as string[] + expect(removeArgs.at(-1)).toContain('rm -rf --') + expect(removeArgs.at(-1)).toContain( + String.raw`rm -rf -- '\''/mnt/c/Users/me/repo feature'\''` + ) + expect(rmMock).not.toHaveBeenCalled() + }) + }) +}) diff --git a/src/main/local-worktree-filesystem.ts b/src/main/local-worktree-filesystem.ts new file mode 100644 index 00000000000..e2f1313e120 --- /dev/null +++ b/src/main/local-worktree-filesystem.ts @@ -0,0 +1,121 @@ +import { execFile } from 'node:child_process' +import { lstat, readFile, rm } from 'node:fs/promises' +import { + buildWslLoginShellCommand, + escapeWslShCommandForWindows, + quotePosixShell +} from '../shared/wsl-login-shell-command' +import { toLinuxPath } from './wsl' +import type { ReadPath, StatPath } from './worktree-orphan-gitdir-proof' + +type LocalWorktreeFilesystemOptions = { + wslDistro?: string +} + +type LocalWorktreePathAccess = { + statPath: StatPath + readPath: ReadPath +} + +type ExecFileTextResult = { + stdout: string + stderr: string +} + +const WSL_FILE_OPERATION_TIMEOUT_MS = 30_000 + +function shouldUseWslFilesystem(options: LocalWorktreeFilesystemOptions): boolean { + return process.platform === 'win32' && !!options.wslDistro?.trim() +} + +function execFileText( + file: string, + args: string[], + options: { timeout: number } +): Promise { + return new Promise((resolve, reject) => { + execFile( + file, + args, + { encoding: 'utf8', timeout: options.timeout }, + (error, stdout, stderr) => { + if (error) { + reject(error) + return + } + resolve({ + stdout: typeof stdout === 'string' ? stdout : String(stdout ?? ''), + stderr: typeof stderr === 'string' ? stderr : String(stderr ?? '') + }) + } + ) + }) +} + +function runWslLoginShellCommand(distro: string, command: string): Promise { + return execFileText( + 'wsl.exe', + [ + '-d', + distro, + '--', + 'sh', + '-lc', + escapeWslShCommandForWindows(buildWslLoginShellCommand(command)) + ], + { timeout: WSL_FILE_OPERATION_TIMEOUT_MS } + ) +} + +export function toLocalWorktreeRuntimePath( + targetPath: string, + options: LocalWorktreeFilesystemOptions = {} +): string { + return shouldUseWslFilesystem(options) ? toLinuxPath(targetPath) : targetPath +} + +export function getLocalWorktreePathAccess( + options: LocalWorktreeFilesystemOptions = {} +): LocalWorktreePathAccess { + const distro = options.wslDistro?.trim() + if (!shouldUseWslFilesystem(options) || !distro) { + return { + statPath: lstat, + readPath: (path) => readFile(path, 'utf8') + } + } + + return { + statPath: async (path) => { + const target = quotePosixShell(toLinuxPath(path)) + const { stdout } = await runWslLoginShellCommand( + distro, + [ + `target=${target}`, + 'if [ -L "$target" ]; then printf symlink; elif [ -f "$target" ]; then printf file; elif [ -d "$target" ]; then printf directory; else exit 2; fi' + ].join('\n') + ) + return { type: stdout.trim() } + }, + readPath: async (path) => { + const target = quotePosixShell(toLinuxPath(path)) + const { stdout } = await runWslLoginShellCommand(distro, `cat -- ${target}`) + return stdout + } + } +} + +export async function removeLocalWorktreePath( + targetPath: string, + options: LocalWorktreeFilesystemOptions = {} +): Promise { + const distro = options.wslDistro?.trim() + if (!shouldUseWslFilesystem(options) || !distro) { + await rm(targetPath, { recursive: true, force: true }) + return + } + + // Why: WSL-owned worktree directories may be POSIX paths that Node on + // Windows cannot delete safely. Run the deletion inside the selected distro. + await runWslLoginShellCommand(distro, `rm -rf -- ${quotePosixShell(toLinuxPath(targetPath))}`) +} diff --git a/src/main/memory/collector.test.ts b/src/main/memory/collector.test.ts index e1ff3dda63d..eb1c2a07c26 100644 --- a/src/main/memory/collector.test.ts +++ b/src/main/memory/collector.test.ts @@ -198,7 +198,27 @@ describe('collectMemorySnapshot', () => { }) function mockPsResponse(stdout: string) { - execMock.mockImplementation((_cmd, _opts, cb) => cb(null, { stdout, stderr: '' })) + const processStdout = process.platform === 'win32' ? psFixtureToWmic(stdout) : stdout + execMock.mockImplementation((_cmd, _opts, cb) => + cb(null, { stdout: processStdout, stderr: '' }) + ) + } + + function psFixtureToWmic(stdout: string): string { + return stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const [pid, ppid, _cpu, rssKb] = line.split(/\s+/, 4) + const memory = Number.parseInt(rssKb ?? '', 10) + return [ + `ParentProcessId=${ppid ?? ''}`, + `ProcessId=${pid ?? ''}`, + `WorkingSetSize=${Number.isFinite(memory) && memory > 0 ? memory * 1024 : 0}` + ].join('\r\n') + }) + .join('\r\n\r\n') } it('coalesces concurrent callers onto a single in-flight sweep', async () => { diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index d417ccfdc11..f8103a1d3b9 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -408,6 +408,62 @@ describe('Store', () => { expect(persisted.projectHostSetups).toContainEqual(independentSetup) }) + it('updates and persists a project Windows runtime preference', async () => { + const project = makeProject({ + id: 'project-1', + sourceRepoIds: ['r1'], + localWindowsRuntimePreference: { kind: 'inherit-global' } + }) + writeDataFile({ + ...getDefaultPersistedState(testState.dir), + projects: [project], + projectHostSetups: [ + makeProjectHostSetup({ + id: 'setup-1', + projectId: project.id, + repoId: '' + }) + ] + }) + const store = await createStore() + + const updated = store.updateProject('project-1', { + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + }) + + expect(updated?.localWindowsRuntimePreference).toEqual({ kind: 'wsl', distro: 'Ubuntu' }) + store.flush() + const reloaded = await createStore() + expect(reloaded.getProjects()[0]?.localWindowsRuntimePreference).toEqual({ + kind: 'wsl', + distro: 'Ubuntu' + }) + }) + + it('migrates legacy WSL agent settings into the global Windows runtime default', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { + localAgentRuntime: 'wsl', + localAgentWslDistro: 'Ubuntu' + } + }) + + const store = await createStore() + + expect(store.getSettings().localWindowsRuntimeDefault).toEqual({ + kind: 'wsl', + distro: 'Ubuntu' + }) + store.flush() + expect((readDataFile() as PersistedState).settings.localWindowsRuntimeDefault).toEqual({ + kind: 'wsl', + distro: 'Ubuntu' + }) + }) + it('returns default settings when no data file exists', async () => { const store = await createStore() const settings = store.getSettings() diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 03298e922c7..7c6f03be33c 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -37,6 +37,7 @@ import { normalizeAutomationPrecheck } from '../shared/automation-precheck' import type { PersistedState, Project, + ProjectUpdateArgs, ProjectHostSetup, ProjectHostSetupCreateArgs, ProjectHostSetupCreateResult, @@ -66,6 +67,10 @@ import type { WorkspaceSessionPatch, WorkspaceSessionState } from '../shared/types' +import { + deriveGlobalWindowsRuntimeDefaultFromLegacySettings, + normalizeProjectRuntimePreference +} from '../shared/project-execution-runtime' import { projectHostSetupProjectionFromRepos } from '../shared/project-host-setup-projection' import { buildTaskSourceContextFromRepo, @@ -1910,6 +1915,9 @@ function mergeProjectHostSetupCompatibilityState( repos: readonly Repo[] ): Pick { const projection = projectHostSetupProjectionFromRepos(repos) + const existingProjectsById = new Map( + (state.projects ?? []).map((project) => [project.id, project]) + ) const currentRepoIds = new Set(repos.map((repo) => repo.id)) const projectedProjectIds = new Set(projection.projects.map((project) => project.id)) const projectedSetupIds = new Set(projection.setups.map((setup) => setup.id)) @@ -1930,8 +1938,18 @@ function mergeProjectHostSetupCompatibilityState( ...project, sourceRepoIds: project.sourceRepoIds.filter((repoId) => currentRepoIds.has(repoId)) })) + const projectedProjects = projection.projects.map((project) => { + const existingProject = existingProjectsById.get(project.id) + return existingProject?.localWindowsRuntimePreference + ? { + ...project, + localWindowsRuntimePreference: existingProject.localWindowsRuntimePreference, + updatedAt: Math.max(project.updatedAt, existingProject.updatedAt) + } + : project + }) return { - projects: [...projection.projects, ...independentProjects], + projects: [...projectedProjects, ...independentProjects], projectHostSetups: [...projection.setups, ...independentSetups] } } @@ -2567,6 +2585,16 @@ export class Store { ) { migratedDisabledTuiAgents.push('claude-agent-teams') } + const migratedWindowsRuntimeDefault = + parsed.settings?.localWindowsRuntimeDefault === undefined + ? deriveGlobalWindowsRuntimeDefaultFromLegacySettings(parsed.settings).defaultRuntime + : parsed.settings.localWindowsRuntimeDefault + if ( + parsed.settings?.localWindowsRuntimeDefault === undefined && + migratedWindowsRuntimeDefault.kind === 'wsl' + ) { + this.loadNeedsSave = true + } if (!autoRenameBranchFromWorkDefaultedOn) { this.loadNeedsSave = true } @@ -2625,6 +2653,7 @@ export class Store { experimentalCompactWorktreeCards: undefined, terminalMacOptionAsAlt: migratedOptionAsAlt, terminalMacOptionAsAltMigrated: true, + localWindowsRuntimeDefault: migratedWindowsRuntimeDefault, floatingTerminalEnabled: migratedFloatingTerminalEnabled, floatingTerminalDefaultedForAllUsers: true, floatingTerminalCwd: migratedFloatingTerminalCwd, @@ -3132,6 +3161,25 @@ export class Store { return [...this.state.projects] } + updateProject(id: string, updates: ProjectUpdateArgs['updates']): Project | null { + const project = this.state.projects.find((entry) => entry.id === id) + if (!project) { + return null + } + if ('localWindowsRuntimePreference' in updates) { + if (updates.localWindowsRuntimePreference === undefined) { + delete project.localWindowsRuntimePreference + } else { + project.localWindowsRuntimePreference = normalizeProjectRuntimePreference( + updates.localWindowsRuntimePreference + ) + } + } + project.updatedAt = Date.now() + this.scheduleSave() + return { ...project } + } + getProjectHostSetups(): ProjectHostSetup[] { return [...this.state.projectHostSetups] } diff --git a/src/main/ports/local-workspace-port-scanner.test.ts b/src/main/ports/local-workspace-port-scanner.test.ts index f90954b54b8..6508056ffe9 100644 --- a/src/main/ports/local-workspace-port-scanner.test.ts +++ b/src/main/ports/local-workspace-port-scanner.test.ts @@ -154,6 +154,7 @@ describe('scanWorkspacePorts attribution work', () => { it('normalizes worktree paths once per scan instead of once per port phase', async () => { vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') const resolveSpy = vi.spyOn(path, 'resolve') + const posixResolveSpy = vi.spyOn(path.posix, 'resolve') const invokeCallback = (callback: unknown, stdout: string): void => { if (typeof callback !== 'function') { throw new Error('missing execFile callback') @@ -197,7 +198,11 @@ describe('scanWorkspacePorts attribution work', () => { const worktreePathResolveCalls = resolveSpy.mock.calls.filter( ([input]) => input === '/repo' || input === '/repo/worktrees/feature' ) - expect(worktreePathResolveCalls).toHaveLength(worktrees.length) + const posixWorktreePathResolveCalls = posixResolveSpy.mock.calls.filter( + ([input]) => input === '/repo' || input === '/repo/worktrees/feature' + ) + expect(worktreePathResolveCalls).toHaveLength(0) + expect(posixWorktreePathResolveCalls).toHaveLength(worktrees.length) }) }) diff --git a/src/main/ports/local-workspace-port-scanner.ts b/src/main/ports/local-workspace-port-scanner.ts index 281aa402eed..df6ad1f1e21 100644 --- a/src/main/ports/local-workspace-port-scanner.ts +++ b/src/main/ports/local-workspace-port-scanner.ts @@ -578,6 +578,11 @@ function includesPathBoundary(commandLine: string, normalizedPath: string): bool } function normalizeComparablePath(input: string): string { + if (input.startsWith('/')) { + // Why: command-line evidence for SSH/WSL/POSIX workspaces can be evaluated + // on a Windows host; path.resolve would reinterpret "/repo" as "G:/repo". + return normalizeComparableText(path.posix.resolve(input)) + } return normalizeComparableText(path.resolve(input)) } diff --git a/src/main/project-runtime-git-options.test.ts b/src/main/project-runtime-git-options.test.ts new file mode 100644 index 00000000000..077bd43afd8 --- /dev/null +++ b/src/main/project-runtime-git-options.test.ts @@ -0,0 +1,151 @@ +import { afterEach, describe, expect, it } from 'vitest' +import type { Store } from './persistence' +import type { Project, Repo } from '../shared/types' +import { + getLocalProjectGitExecOptions, + resolveLocalProjectRuntimeForRepo +} from './project-runtime-git-options' +import { _resetWslCachesForTests, _setWslCachesForTests } from './wsl' + +function withPlatform(platform: NodeJS.Platform, run: () => T): T { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { + configurable: true, + value: platform + }) + try { + return run() + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } +} + +function makeRepo(overrides: Partial = {}): Repo { + return { + id: 'repo-1', + displayName: 'Repo', + path: String.raw`C:\repo`, + badgeColor: '#000000', + addedAt: 0, + ...overrides + } +} + +function makeProject(overrides: Partial = {}): Project { + return { + id: 'project-1', + displayName: 'Project', + badgeColor: '#000000', + sourceRepoIds: ['repo-1'], + createdAt: 0, + updatedAt: 0, + ...overrides + } +} + +function makeStore(project: Project): Store { + return { + getProjects: () => [project], + getSettings: () => ({ localWindowsRuntimeDefault: { kind: 'windows-host' } }) + } as unknown as Store +} + +describe('project runtime git options', () => { + afterEach(() => { + _resetWslCachesForTests() + }) + + it('does not probe or repair WSL git routing before capability caches exist', () => { + const project = makeProject({ + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + }) + + const options = withPlatform('win32', () => + getLocalProjectGitExecOptions(makeStore(project), makeRepo()) + ) + + expect(options).toEqual({ cwd: String.raw`C:\repo`, wslDistro: 'Ubuntu' }) + }) + + it('returns repair state for missing cached WSL distro before local git execution', () => { + _setWslCachesForTests({ available: true, distros: ['Debian'] }) + const project = makeProject({ + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + }) + + const runtime = withPlatform('win32', () => + resolveLocalProjectRuntimeForRepo(makeStore(project), makeRepo()) + ) + + expect(runtime).toEqual({ + status: 'repair-required', + repair: { + projectId: 'project-1', + preferredRuntime: { kind: 'wsl', distro: 'Ubuntu' }, + reason: 'wsl-distro-missing', + source: 'project-override', + cacheKey: 'project-1:repair:wsl-distro-missing:Ubuntu' + } + }) + expect(() => + withPlatform('win32', () => getLocalProjectGitExecOptions(makeStore(project), makeRepo())) + ).toThrow('Project runtime requires repair before git execution: wsl-distro-missing') + }) + + it('returns repair state for cached WSL unavailable before local git execution', () => { + _setWslCachesForTests({ available: false, distros: [] }) + const project = makeProject({ + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + }) + + expect(() => + withPlatform('win32', () => getLocalProjectGitExecOptions(makeStore(project), makeRepo())) + ).toThrow('Project runtime requires repair before git execution: wsl-unavailable') + }) + + it('keeps project host override on host even when cached WSL is unavailable', () => { + _setWslCachesForTests({ available: false, distros: [] }) + const project = makeProject({ + localWindowsRuntimePreference: { kind: 'windows-host' } + }) + + const options = withPlatform('win32', () => + getLocalProjectGitExecOptions(makeStore(project), makeRepo()) + ) + + expect(options).toEqual({ cwd: String.raw`C:\repo` }) + }) + + it('does not apply local Windows runtime routing to SSH-owned repos', () => { + const project = makeProject({ + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + }) + + const runtime = withPlatform('win32', () => + resolveLocalProjectRuntimeForRepo( + makeStore(project), + makeRepo({ connectionId: null, executionHostId: 'ssh:target-1' }) + ) + ) + + expect(runtime).toBeUndefined() + }) + + it('does not apply local Windows runtime routing to runtime-owned repos', () => { + const project = makeProject({ + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + }) + + const runtime = withPlatform('win32', () => + resolveLocalProjectRuntimeForRepo( + makeStore(project), + makeRepo({ connectionId: null, executionHostId: 'runtime:env-1' }) + ) + ) + + expect(runtime).toBeUndefined() + }) +}) diff --git a/src/main/project-runtime-git-options.ts b/src/main/project-runtime-git-options.ts new file mode 100644 index 00000000000..5c6eccdb071 --- /dev/null +++ b/src/main/project-runtime-git-options.ts @@ -0,0 +1,43 @@ +import type { Store } from './persistence' +import type { Repo } from '../shared/types' +import { resolveLocalProjectRuntimeForRepo } from './local-project-runtime-resolution' + +export { resolveLocalProjectRuntimeForRepo } from './local-project-runtime-resolution' + +export type LocalProjectGitExecOptions = { + cwd: string + wslDistro?: string +} + +export type LocalProjectWorktreeGitOptions = { + wslDistro?: string +} + +export function getLocalProjectGitExecOptions( + store: Store, + repo: Repo +): LocalProjectGitExecOptions { + // Why: local git must run in the same resolved project runtime as agents, + // terminals, and preflight; repair states must not silently fall back to host git. + const projectRuntime = resolveLocalProjectRuntimeForRepo(store, repo) + if (!projectRuntime) { + return { cwd: repo.path } + } + if (projectRuntime.status === 'repair-required') { + throw new Error( + `Project runtime requires repair before git execution: ${projectRuntime.repair.reason}` + ) + } + if (projectRuntime.runtime.kind === 'wsl') { + return { cwd: repo.path, wslDistro: projectRuntime.runtime.distro } + } + return { cwd: repo.path } +} + +export function getLocalProjectWorktreeGitOptions( + store: Store, + repo: Repo +): LocalProjectWorktreeGitOptions { + const { wslDistro } = getLocalProjectGitExecOptions(store, repo) + return wslDistro ? { wslDistro } : {} +} diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index 19c8cd81d0b..9beba9aceac 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -344,6 +344,7 @@ describe('LocalPtyProvider', () => { await provider.spawn({ cols: 80, rows: 24, + worktreeId: 'repo-1::C:\\Users\\jin\\repo', cwd: 'C:\\Users\\jin\\repo', shellOverride: 'wsl.exe', terminalWindowsWslDistro: 'Debian' @@ -360,6 +361,7 @@ describe('LocalPtyProvider', () => { expect.stringContaining("cd '/mnt/c/Users/jin/repo'") ]) expect(spawnCall[1][5]).toContain('exec "\\$_orca_wsl_shell" -l') + expect(spawnCall[2].env.HISTFILE).toContain('terminal-history-wsl/Debian') }) it('marks Orca terminal handle for WSL import when buildSpawnEnv opts in', async () => { diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 7d301cfc5ef..9adcd940e43 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -497,10 +497,15 @@ export class LocalPtyProvider implements IPtyProvider { const historyEnabled = worktreeId && (this.opts.isHistoryEnabled?.() ?? true) // Resolve the effective shell kind for history injection. For WSL, the // outer executable is wsl.exe but the inner login shell is bash. - const effectiveShellPath = wslInfo ? 'bash' : shellPath + const isWslTerminal = + Boolean(wslInfo || worktreeWslContext || preferredWslContext) || + pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe' + const effectiveShellPath = isWslTerminal ? 'bash' : shellPath let historyResult: ReturnType | null = null if (historyEnabled) { - historyResult = injectHistoryEnv(finalEnv, worktreeId, effectiveShellPath, cwd) + historyResult = injectHistoryEnv(finalEnv, worktreeId, effectiveShellPath, cwd, { + wslDistro: preferredWslContext?.distro ?? worktreeWslContext?.distro ?? null + }) logHistoryInjection(worktreeId, historyResult) } diff --git a/src/main/pty/shell-startup-env.ts b/src/main/pty/shell-startup-env.ts index 96ba85aae6d..2cbec5fda76 100644 --- a/src/main/pty/shell-startup-env.ts +++ b/src/main/pty/shell-startup-env.ts @@ -1,5 +1,5 @@ import { existsSync, readFileSync } from 'fs' -import { basename, join } from 'path' +import { posix } from 'path' // Why: only files the user's actual shell would source. Mixing zsh and bash // files breaks the "last assignment wins matches the live shell" guarantee — @@ -54,12 +54,12 @@ function shellStartupFilePaths(home: string, shell: string | undefined): readonl return zshStartupFilePaths(home) } - const name = basename(shell).toLowerCase() + const name = posix.basename(shell).toLowerCase() if (name === 'zsh') { return zshStartupFilePaths(home) } if (name === 'bash') { - return BASH_LOGIN_FILES.map((file) => join(home, file)) + return BASH_LOGIN_FILES.map((file) => posix.join(home, file)) } // Why: unsupported explicit shells (fish, nushell, custom wrappers) do not // use Orca's zsh/bash shell-ready startup files, so scanning those files @@ -68,13 +68,13 @@ function shellStartupFilePaths(home: string, shell: string | undefined): readonl } function zshStartupFilePaths(home: string): readonly string[] { - const zshEnvPath = join(home, ZSH_ENV_FILE) + const zshEnvPath = posix.join(home, ZSH_ENV_FILE) const zshEnv = readStartupFile(zshEnvPath) // Why: zsh sources ~/.zshenv first, then uses any ZDOTDIR exported there // for .zprofile/.zshrc/.zlogin. Mirror that enough for static env discovery // so users who keep zsh config in ~/.config/zsh do not lose overlay sources. const zshDir = zshEnv ? (parseExportedValue(zshEnv, 'ZDOTDIR', home) ?? home) : home - return [zshEnvPath, ...ZSH_AFTER_ENV_FILES.map((file) => join(zshDir, file))] + return [zshEnvPath, ...ZSH_AFTER_ENV_FILES.map((file) => posix.join(zshDir, file))] } function unquoteShellValue(value: string): { text: string; quoted: '"' | "'" | null } { diff --git a/src/main/rate-limits/claude-rate-limit-target.test.ts b/src/main/rate-limits/claude-rate-limit-target.test.ts index 8ae7722f656..e6789814f41 100644 --- a/src/main/rate-limits/claude-rate-limit-target.test.ts +++ b/src/main/rate-limits/claude-rate-limit-target.test.ts @@ -41,13 +41,31 @@ describe('getInitialClaudeRateLimitTarget', () => { ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) }) - it('uses the configured WSL agent runtime and distro', () => { + it('ignores stale terminal WSL distro when account runtime is WSL default', () => { + expect( + getInitialClaudeRateLimitTarget( + { + ...getDefaultSettings('/tmp'), + localAccountRuntime: 'wsl', + localAccountWslDistro: null, + terminalWindowsWslDistro: 'Debian', + activeClaudeManagedAccountIdsByRuntime: { + host: 'host-account-1', + wsl: {} + } + }, + 'win32' + ) + ).toEqual({ runtime: 'wsl', wslDistro: null }) + }) + + it('uses the global WSL project runtime default when account runtime is unset', () => { expect( getInitialClaudeRateLimitTarget( legacySettingsWithoutAccountRuntime({ ...getDefaultSettings('/tmp'), - localAgentRuntime: 'wsl', - localAgentWslDistro: 'Ubuntu', + localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' }, + localAgentRuntime: 'host', terminalWindowsWslDistro: 'Debian' }), 'win32' @@ -55,17 +73,20 @@ describe('getInitialClaudeRateLimitTarget', () => { ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) }) - it('uses the Windows WSL terminal setting when agent runtime is implicit', () => { + it('ignores stale legacy WSL agent and terminal settings when the project default is host', () => { expect( getInitialClaudeRateLimitTarget( legacySettingsWithoutAccountRuntime({ ...getDefaultSettings('/tmp'), + localWindowsRuntimeDefault: { kind: 'windows-host' }, + localAgentRuntime: 'wsl', + localAgentWslDistro: 'Ubuntu', terminalWindowsShell: 'wsl.exe', - terminalWindowsWslDistro: 'Ubuntu' + terminalWindowsWslDistro: 'Debian' }), 'win32' ) - ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + ).toEqual({ runtime: 'host' }) }) it('uses a single WSL-only active account after restart', () => { diff --git a/src/main/rate-limits/claude-rate-limit-target.ts b/src/main/rate-limits/claude-rate-limit-target.ts index 68b33cdd9a8..f98a3e2645a 100644 --- a/src/main/rate-limits/claude-rate-limit-target.ts +++ b/src/main/rate-limits/claude-rate-limit-target.ts @@ -4,11 +4,10 @@ import { normalizeClaudeRuntimeSelection, type ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection' - -function normalizeOptionalDistro(value: string | null | undefined): string | null { - const trimmed = value?.trim() - return trimmed ? trimmed : null -} +import { + getProjectRuntimeRateLimitTarget, + normalizeOptionalDistro +} from './project-runtime-rate-limit-target' function getSingleSelectedWslDistro(settings: GlobalSettings): string | null { const selection = normalizeClaudeRuntimeSelection(settings) @@ -34,23 +33,13 @@ export function getInitialClaudeRateLimitTarget( runtime: 'wsl', wslDistro: normalizeOptionalDistro(settings.localAccountWslDistro) ?? - normalizeOptionalDistro(settings.terminalWindowsWslDistro) ?? getSingleSelectedWslDistro(settings) } } - if ( - settings.localAgentRuntime === 'wsl' || - (settings.localAgentRuntime == null && - platform === 'win32' && - settings.terminalWindowsShell === 'wsl.exe') - ) { - return { - runtime: 'wsl', - wslDistro: - normalizeOptionalDistro(settings.localAgentWslDistro) ?? - normalizeOptionalDistro(settings.terminalWindowsWslDistro) - } + const projectRuntimeTarget = getProjectRuntimeRateLimitTarget(settings, platform) + if (projectRuntimeTarget) { + return projectRuntimeTarget } const selection = normalizeClaudeRuntimeSelection(settings) diff --git a/src/main/rate-limits/codex-rate-limit-target.test.ts b/src/main/rate-limits/codex-rate-limit-target.test.ts index 739adc17daa..fbe87616a5a 100644 --- a/src/main/rate-limits/codex-rate-limit-target.test.ts +++ b/src/main/rate-limits/codex-rate-limit-target.test.ts @@ -41,13 +41,31 @@ describe('getInitialCodexRateLimitTarget', () => { ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) }) - it('uses the configured WSL agent runtime and distro', () => { + it('ignores stale terminal WSL distro when account runtime is WSL default', () => { + expect( + getInitialCodexRateLimitTarget( + { + ...getDefaultSettings('/tmp'), + localAccountRuntime: 'wsl', + localAccountWslDistro: null, + terminalWindowsWslDistro: 'Debian', + activeCodexManagedAccountIdsByRuntime: { + host: 'host-account-1', + wsl: {} + } + }, + 'win32' + ) + ).toEqual({ runtime: 'wsl', wslDistro: null }) + }) + + it('uses the global WSL project runtime default when account runtime is unset', () => { expect( getInitialCodexRateLimitTarget( legacySettingsWithoutAccountRuntime({ ...getDefaultSettings('/tmp'), - localAgentRuntime: 'wsl', - localAgentWslDistro: 'Ubuntu', + localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' }, + localAgentRuntime: 'host', terminalWindowsWslDistro: 'Debian' }), 'win32' @@ -55,17 +73,20 @@ describe('getInitialCodexRateLimitTarget', () => { ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) }) - it('uses the Windows WSL terminal setting when agent runtime is implicit', () => { + it('ignores stale legacy WSL agent and terminal settings when the project default is host', () => { expect( getInitialCodexRateLimitTarget( legacySettingsWithoutAccountRuntime({ ...getDefaultSettings('/tmp'), + localWindowsRuntimeDefault: { kind: 'windows-host' }, + localAgentRuntime: 'wsl', + localAgentWslDistro: 'Ubuntu', terminalWindowsShell: 'wsl.exe', - terminalWindowsWslDistro: 'Ubuntu' + terminalWindowsWslDistro: 'Debian' }), 'win32' ) - ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + ).toEqual({ runtime: 'host' }) }) it('uses a single WSL-only active account after restart', () => { diff --git a/src/main/rate-limits/codex-rate-limit-target.ts b/src/main/rate-limits/codex-rate-limit-target.ts index 0afb9d19e47..67bf04fe9db 100644 --- a/src/main/rate-limits/codex-rate-limit-target.ts +++ b/src/main/rate-limits/codex-rate-limit-target.ts @@ -4,11 +4,10 @@ import { normalizeCodexRuntimeSelection, type CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' - -function normalizeOptionalDistro(value: string | null | undefined): string | null { - const trimmed = value?.trim() - return trimmed ? trimmed : null -} +import { + getProjectRuntimeRateLimitTarget, + normalizeOptionalDistro +} from './project-runtime-rate-limit-target' function getSingleSelectedWslDistro(settings: GlobalSettings): string | null { const selection = normalizeCodexRuntimeSelection(settings) @@ -34,23 +33,13 @@ export function getInitialCodexRateLimitTarget( runtime: 'wsl', wslDistro: normalizeOptionalDistro(settings.localAccountWslDistro) ?? - normalizeOptionalDistro(settings.terminalWindowsWslDistro) ?? getSingleSelectedWslDistro(settings) } } - if ( - settings.localAgentRuntime === 'wsl' || - (settings.localAgentRuntime == null && - platform === 'win32' && - settings.terminalWindowsShell === 'wsl.exe') - ) { - return { - runtime: 'wsl', - wslDistro: - normalizeOptionalDistro(settings.localAgentWslDistro) ?? - normalizeOptionalDistro(settings.terminalWindowsWslDistro) - } + const projectRuntimeTarget = getProjectRuntimeRateLimitTarget(settings, platform) + if (projectRuntimeTarget) { + return projectRuntimeTarget } const selection = normalizeCodexRuntimeSelection(settings) diff --git a/src/main/rate-limits/project-runtime-rate-limit-target.ts b/src/main/rate-limits/project-runtime-rate-limit-target.ts new file mode 100644 index 00000000000..b6504235aa3 --- /dev/null +++ b/src/main/rate-limits/project-runtime-rate-limit-target.ts @@ -0,0 +1,29 @@ +import type { GlobalSettings } from '../../shared/types' +import { normalizeGlobalWindowsRuntimeDefault } from '../../shared/project-execution-runtime' + +export type AccountRateLimitRuntimeTarget = + | { runtime: 'host' } + | { runtime: 'wsl'; wslDistro: string | null } + +export function getProjectRuntimeRateLimitTarget( + settings: GlobalSettings, + platform: NodeJS.Platform +): AccountRateLimitRuntimeTarget | null { + if (platform !== 'win32') { + return null + } + + const runtimeDefault = normalizeGlobalWindowsRuntimeDefault(settings.localWindowsRuntimeDefault) + if (runtimeDefault.kind !== 'wsl') { + return null + } + + // Why: account quota polling has no project id, so its best default is the + // global project runtime instead of stale terminal/agent WSL settings. + return { runtime: 'wsl', wslDistro: runtimeDefault.distro } +} + +export function normalizeOptionalDistro(value: string | null | undefined): string | null { + const trimmed = value?.trim() + return trimmed ? trimmed : null +} diff --git a/src/main/repo-worktrees.ts b/src/main/repo-worktrees.ts index 4f5832a2a1d..ca3bcf359c9 100644 --- a/src/main/repo-worktrees.ts +++ b/src/main/repo-worktrees.ts @@ -4,6 +4,10 @@ import { isFolderRepo } from '../shared/repo-kind' import { getSshGitProvider } from './providers/ssh-git-dispatch' import { areWorktreePathsEqual } from './ipc/worktree-logic' +type LocalRepoWorktreeListOptions = { + wslDistro?: string +} + export function isRepoRoot(repos: Repo[], resolvedTarget: string): boolean { return repos.some( (repo) => !repo.connectionId && areWorktreePathsEqual(repo.path, resolvedTarget) @@ -23,7 +27,10 @@ export function createFolderWorktree(repo: Repo): GitWorktreeInfo { } } -export async function listRepoWorktrees(repo: Repo): Promise { +export async function listRepoWorktrees( + repo: Repo, + options: LocalRepoWorktreeListOptions = {} +): Promise { if (isFolderRepo(repo)) { return [createFolderWorktree(repo)] } @@ -34,5 +41,7 @@ export async function listRepoWorktrees(repo: Repo): Promise // local git against a server path. return provider ? await provider.listWorktrees(repo.path) : [] } - return await listWorktrees(repo.path) + return options.wslDistro + ? await listWorktrees(repo.path, options) + : await listWorktrees(repo.path) } diff --git a/src/main/runtime/claude-agent-teams-shim-env.test.ts b/src/main/runtime/claude-agent-teams-shim-env.test.ts index 5eb3abcf119..466ba6fb961 100644 --- a/src/main/runtime/claude-agent-teams-shim-env.test.ts +++ b/src/main/runtime/claude-agent-teams-shim-env.test.ts @@ -50,12 +50,21 @@ describe('claude agent teams shim env', () => { } }) - expect(plan).toMatchObject({ - command: "claude --teammate-mode auto 'hello'", - env: expect.objectContaining({ TMUX_PANE: '%1' }), - envToDelete: ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR'] - }) - expect(capturedShimBin).toBe(cliPath) + if (process.platform === 'win32') { + expect(plan).toMatchObject({ + command: "claude --teammate-mode in-process 'hello'", + env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1' } + }) + expect(plan?.envToDelete).toBeUndefined() + expect(capturedShimBin).toBe('') + } else { + expect(plan).toMatchObject({ + command: "claude --teammate-mode auto 'hello'", + env: expect.objectContaining({ TMUX_PANE: '%1' }), + envToDelete: ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR'] + }) + expect(capturedShimBin).toBe(cliPath) + } await expect( buildClaudeAgentTeamsLaunchPlan({ diff --git a/src/main/runtime/orca-runtime-files-watch.test.ts b/src/main/runtime/orca-runtime-files-watch.test.ts index e222d5ccb91..80fb652227d 100644 --- a/src/main/runtime/orca-runtime-files-watch.test.ts +++ b/src/main/runtime/orca-runtime-files-watch.test.ts @@ -60,6 +60,8 @@ function createRuntimeFileCommands(rootPath: string) { describe('RuntimeFileCommands file watching', () => { const originalPlatform = process.platform + // Why: Windows runtime watches intentionally use fs.watch instead of the worker path. + const posixWorkerIt = process.platform === 'win32' ? it.skip : it beforeEach(() => { vi.useFakeTimers() @@ -123,7 +125,7 @@ describe('RuntimeFileCommands file watching', () => { // Issue #5308: the local recursive watch runs in a worker thread so // @parcel/watcher's blocking initial crawl can't starve the serve runtime. - it('delegates local recursive watching to the worker thread', async () => { + posixWorkerIt('delegates local recursive watching to the worker thread', async () => { resolveAuthorizedPathMock.mockResolvedValue('/home5/Brian') statMock.mockResolvedValue({ isDirectory: () => true }) @@ -153,7 +155,7 @@ describe('RuntimeFileCommands file watching', () => { expect(workerDispose).toHaveBeenCalledTimes(1) }) - it('propagates a worker watch failure to the caller', async () => { + posixWorkerIt('propagates a worker watch failure to the caller', async () => { resolveAuthorizedPathMock.mockResolvedValue('/repo') statMock.mockResolvedValue({ isDirectory: () => true }) watchInWorkerMock.mockRejectedValue(new Error('worker_failed')) @@ -162,7 +164,7 @@ describe('RuntimeFileCommands file watching', () => { await expect(commands.watchFileExplorer('id:wt-1', vi.fn())).rejects.toThrow('worker_failed') }) - it('tracks worker unsubscribe work so shutdown can await it', async () => { + posixWorkerIt('tracks worker unsubscribe work so shutdown can await it', async () => { resolveAuthorizedPathMock.mockResolvedValue('/repo') statMock.mockResolvedValue({ isDirectory: () => true }) diff --git a/src/main/runtime/orca-runtime-files.test.ts b/src/main/runtime/orca-runtime-files.test.ts index d02f97600b8..c794fbf3998 100644 --- a/src/main/runtime/orca-runtime-files.test.ts +++ b/src/main/runtime/orca-runtime-files.test.ts @@ -16,10 +16,12 @@ const { statMock, watchInWorkerMock, checkRgAvailableMock, + getLocalGitOptionsForRegisteredWorktreeMock, wslAwareSpawnMock, watchMock } = vi.hoisted(() => ({ checkRgAvailableMock: vi.fn(), + getLocalGitOptionsForRegisteredWorktreeMock: vi.fn(), lstatMock: vi.fn(), readdirMock: vi.fn(), renameMock: vi.fn(), @@ -73,6 +75,10 @@ vi.mock('../ipc/rg-availability', () => ({ checkRgAvailable: checkRgAvailableMock })) +vi.mock('../ipc/local-worktree-runtime-options', () => ({ + getLocalGitOptionsForRegisteredWorktree: getLocalGitOptionsForRegisteredWorktreeMock +})) + vi.mock('../providers/ssh-filesystem-dispatch', () => ({ getSshFilesystemProvider: vi.fn(), SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE: @@ -163,7 +169,9 @@ describe('RuntimeFileCommands', () => { watchInWorkerMock.mockReset() watchMock.mockReset() checkRgAvailableMock.mockReset() + getLocalGitOptionsForRegisteredWorktreeMock.mockReset() wslAwareSpawnMock.mockReset() + getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({}) readdirMock.mockResolvedValue([]) lstatMock.mockRejectedValue(enoent()) renameMock.mockResolvedValue(undefined) @@ -400,6 +408,10 @@ describe('RuntimeFileCommands', () => { }) it('delegates local recursive watching to the worker thread', async () => { + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'linux' + }) resolveAuthorizedPathMock.mockResolvedValue('/repo') statMock.mockResolvedValue({ isDirectory: () => true }) const dispose = vi.fn() @@ -447,6 +459,48 @@ describe('RuntimeFileCommands', () => { expect(child.listenerCount('close')).toBe(0) }) + it('routes runtime rg searches through the registered WSL project runtime', async () => { + const resolveRuntimeGitTarget = vi.fn(async () => ({ + worktree: { + id: 'wt-1', + repoId: 'repo-1', + path: 'C:\\repo' + }, + connectionId: null + })) + const { commands, store } = createRuntimeFileCommands({ resolveRuntimeGitTarget }) + const child = createRuntimeSearchChild() + resolveAuthorizedPathMock.mockResolvedValue('C:\\repo') + checkRgAvailableMock.mockResolvedValue(true) + getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' }) + wslAwareSpawnMock.mockReturnValue(child) + + const resultPromise = commands.searchRuntimeFiles('id:wt-1', { + query: 'needle', + maxResults: 10 + }) + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + child.emit('close') + + await expect(resultPromise).resolves.toMatchObject({ files: [] }) + expect(getLocalGitOptionsForRegisteredWorktreeMock).toHaveBeenCalledWith( + store, + 'C:\\repo', + 'C:\\repo' + ) + expect(checkRgAvailableMock).toHaveBeenCalledWith('C:\\repo', 'Ubuntu') + expect(wslAwareSpawnMock).toHaveBeenCalledWith( + 'rg', + expect.any(Array), + expect.objectContaining({ + cwd: 'C:\\repo', + wslDistro: 'Ubuntu' + }) + ) + }) + describe('resolveTerminalPath', () => { function statAsFile() { resolveAuthorizedPathMock.mockImplementation(async (p: string) => p) diff --git a/src/main/runtime/orca-runtime-files.ts b/src/main/runtime/orca-runtime-files.ts index 39d54306286..05cd446ad44 100644 --- a/src/main/runtime/orca-runtime-files.ts +++ b/src/main/runtime/orca-runtime-files.ts @@ -43,6 +43,7 @@ import { parseWslPath, toWindowsWslPath } from '../wsl' import { isENOENT, resolveAuthorizedPath } from '../ipc/filesystem-auth' import { listQuickOpenFiles } from '../ipc/filesystem-list-files' import { searchWithGitGrep } from '../ipc/filesystem-search-git' +import { getLocalGitOptionsForRegisteredWorktree } from '../ipc/local-worktree-runtime-options' import { checkRgAvailable } from '../ipc/rg-availability' import { listMarkdownDocuments, @@ -790,14 +791,20 @@ export class RuntimeFileCommands { rootPath: string, options: SearchOptions ): Promise { - const authorizedRootPath = await resolveAuthorizedPath(rootPath, this.host.requireStore()) + const store = this.host.requireStore() + const authorizedRootPath = await resolveAuthorizedPath(rootPath, store) + const localGitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + rootPath, + authorizedRootPath + ) const maxResults = Math.max( 1, Math.min(options.maxResults ?? DEFAULT_SEARCH_MAX_RESULTS, DEFAULT_SEARCH_MAX_RESULTS) ) - const rgAvailable = await checkRgAvailable(authorizedRootPath) + const rgAvailable = await checkRgAvailable(authorizedRootPath, localGitOptions.wslDistro) if (!rgAvailable) { - return searchWithGitGrep(authorizedRootPath, options, maxResults) + return searchWithGitGrep(authorizedRootPath, options, maxResults, localGitOptions) } return new Promise((resolvePromise) => { @@ -853,6 +860,7 @@ export class RuntimeFileCommands { const nextChild = wslAwareSpawn('rg', rgArgs, { cwd: authorizedRootPath, + ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), stdio: ['ignore', 'pipe', 'pipe'] }) child = nextChild diff --git a/src/main/runtime/orca-runtime-git.test.ts b/src/main/runtime/orca-runtime-git.test.ts index 4187f1d94be..b2adc5bcb10 100644 --- a/src/main/runtime/orca-runtime-git.test.ts +++ b/src/main/runtime/orca-runtime-git.test.ts @@ -112,7 +112,7 @@ describe('RuntimeGitCommands', () => { await expect(commands.abortRuntimeGitMerge('id:wt-1')).resolves.toEqual({ ok: true }) - expect(mocks.abortMerge).toHaveBeenCalledWith(worktreePath) + expect(mocks.abortMerge).toHaveBeenCalledWith(worktreePath, {}) }) it('aborts a remote merge through the SSH git provider', async () => { @@ -140,7 +140,7 @@ describe('RuntimeGitCommands', () => { await expect(commands.abortRuntimeGitRebase('id:wt-1')).resolves.toEqual({ ok: true }) - expect(mocks.abortRebase).toHaveBeenCalledWith(worktreePath) + expect(mocks.abortRebase).toHaveBeenCalledWith(worktreePath, {}) }) it('aborts a remote rebase through the SSH git provider', async () => { @@ -171,7 +171,7 @@ describe('RuntimeGitCommands', () => { branch: 'feature/x' }) - expect(mocks.checkoutBranch).toHaveBeenCalledWith(worktreePath, 'feature/x') + expect(mocks.checkoutBranch).toHaveBeenCalledWith(worktreePath, 'feature/x', {}) }) it('checks out a remote branch through the SSH git provider', async () => { @@ -205,7 +205,7 @@ describe('RuntimeGitCommands', () => { branches: ['main', 'feature/x'] }) - expect(mocks.listLocalBranches).toHaveBeenCalledWith(worktreePath) + expect(mocks.listLocalBranches).toHaveBeenCalledWith(worktreePath, {}) }) it('lists remote local branches through the SSH git provider', async () => { @@ -295,6 +295,62 @@ describe('RuntimeGitCommands', () => { ) }) + it('routes local WSL project runtime commit-message generation through the runtime target', async () => { + const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-')) + tempDirs.push(worktreePath) + const context = { + branch: 'main', + stagedSummary: 'M\tREADME.md', + stagedPatch: '+hello' + } + const params = { agentId: 'codex', model: 'gpt-5.4-mini', thinkingLevel: 'low' } + const prepareForCodexLaunch = vi.fn(() => '\\\\wsl.localhost\\Ubuntu\\home\\tester\\.codex') + mocks.resolveCommitMessageSettings.mockReturnValue({ ok: true, params }) + mocks.getStagedCommitContext.mockResolvedValue(context) + mocks.generateCommitMessageFromContext.mockResolvedValue({ + success: true, + message: 'docs: update readme' + }) + const commands = new RuntimeGitCommands({ + resolveRuntimeGitTarget: async () => ({ + worktree: makeWorktree(worktreePath), + localGitOptions: { wslDistro: 'Ubuntu' } + }), + getRuntimeSettings: () => + ({ + commitMessageAi: { enabled: true, agentId: 'codex' }, + agentCmdOverrides: {}, + enableGitHubAttribution: false + }) as GlobalSettings, + getCommitMessageAgentEnvironment: () => ({ + prepareForCodexLaunch + }) + }) + + await expect(commands.generateRuntimeCommitMessage('id:wt-1')).resolves.toEqual({ + success: true, + message: 'docs: update readme' + }) + + expect(mocks.getStagedCommitContext).toHaveBeenCalledWith(worktreePath, { + wslDistro: 'Ubuntu' + }) + expect(prepareForCodexLaunch).toHaveBeenCalledWith({ + runtime: 'wsl', + wslDistro: 'Ubuntu' + }) + expect(mocks.generateCommitMessageFromContext).toHaveBeenCalledWith( + context, + params, + expect.objectContaining({ + kind: 'local', + cwd: worktreePath, + wslDistro: 'Ubuntu', + env: expect.objectContaining({ CODEX_HOME: '/home/tester/.codex' }) + }) + ) + }) + it('uses one-shot resolved params before runtime commit-message defaults', async () => { const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-')) tempDirs.push(worktreePath) diff --git a/src/main/runtime/orca-runtime-git.ts b/src/main/runtime/orca-runtime-git.ts index 476632a9d0f..c2cd775ac0e 100644 --- a/src/main/runtime/orca-runtime-git.ts +++ b/src/main/runtime/orca-runtime-git.ts @@ -62,15 +62,20 @@ import { generateCommitMessageFromContext, generatePullRequestFieldsFromContext, resolveCommitMessageSettings, + type CommitMessageGenerationTarget, type DiscoverCommitMessageModelsResult, type GenerateCommitMessageResult, type GeneratePullRequestFieldsResult } from '../text-generation/commit-message-text-generation' -import type { CommitMessageAgentEnvironmentResolvers } from '../text-generation/commit-message-agent-environment' +import type { + CommitMessageAgentEnvironmentResolvers, + CommitMessageAgentRuntimeTarget +} from '../text-generation/commit-message-agent-environment' import { prepareLocalCommitMessageAgentEnv } from '../text-generation/commit-message-agent-environment' import { getPullRequestDraftContext } from '../text-generation/pull-request-context' import { normalizeRuntimeRelativePath } from './runtime-relative-paths' import { gitExecFileAsync } from '../git/runner' +import type { GitRuntimeOptions } from '../git/git-runtime-options' import { resolveHostedReviewBodyForGeneration } from '../source-control/pull-request-template' import type { HostedReviewProvider } from '../../shared/hosted-review' @@ -117,10 +122,39 @@ function normalizeRuntimeGitRelativePath(filePath: string): string { return relativePath } +type RuntimeGitTarget = { + worktree: ResolvedRuntimeGitWorktree + repo?: Repo + connectionId?: string + localGitOptions?: GitRuntimeOptions +} + +function localGitOptionsForTarget(target: RuntimeGitTarget): GitRuntimeOptions { + return target.connectionId ? {} : (target.localGitOptions ?? {}) +} + +function localAgentRuntimeTargetForTarget( + target: RuntimeGitTarget +): CommitMessageAgentRuntimeTarget { + const wslDistro = localGitOptionsForTarget(target).wslDistro + return wslDistro ? { runtime: 'wsl', wslDistro } : { runtime: 'host' } +} + +function localTextGenerationTargetForTarget( + target: RuntimeGitTarget, + env?: NodeJS.ProcessEnv +): Extract { + const wslDistro = localGitOptionsForTarget(target).wslDistro + return { + kind: 'local', + cwd: target.worktree.path, + ...(wslDistro ? { wslDistro } : {}), + ...(env ? { env } : {}) + } +} + export type RuntimeGitCommandHost = { - resolveRuntimeGitTarget( - selector: string - ): Promise<{ worktree: ResolvedRuntimeGitWorktree; repo?: Repo; connectionId?: string }> + resolveRuntimeGitTarget(selector: string): Promise getRuntimeSettings(): GlobalSettings getCommitMessageAgentEnvironment?(): CommitMessageAgentEnvironmentResolvers | undefined } @@ -142,9 +176,10 @@ export class RuntimeGitCommands { ? provider.getStatus(target.worktree.path, options) : provider.getStatus(target.worktree.path) } + const gitOptions = localGitOptionsForTarget(target) return options - ? getGitStatus(target.worktree.path, options) - : getGitStatus(target.worktree.path) + ? getGitStatus(target.worktree.path, { ...options, ...gitOptions }) + : getGitStatus(target.worktree.path, gitOptions) } async checkRuntimeGitIgnoredPaths( @@ -159,7 +194,7 @@ export class RuntimeGitCommands { } return provider.checkIgnoredPaths(target.worktree.path, relativePaths) } - return checkIgnoredPaths(target.worktree.path, relativePaths) + return checkIgnoredPaths(target.worktree.path, relativePaths, localGitOptionsForTarget(target)) } async getRuntimeGitHistory( @@ -174,7 +209,10 @@ export class RuntimeGitCommands { } return provider.getHistory(target.worktree.path, options) } - return getGitHistory(target.worktree.path, options) + return getGitHistory(target.worktree.path, { + ...options, + ...localGitOptionsForTarget(target) + }) } async getRuntimeGitConflictOperation(worktreeSelector: string): Promise { @@ -199,7 +237,7 @@ export class RuntimeGitCommands { await provider.abortMerge(target.worktree.path) return { ok: true } } - await abortMerge(target.worktree.path) + await abortMerge(target.worktree.path, localGitOptionsForTarget(target)) return { ok: true } } @@ -213,7 +251,7 @@ export class RuntimeGitCommands { await provider.abortRebase(target.worktree.path) return { ok: true } } - await abortRebase(target.worktree.path) + await abortRebase(target.worktree.path, localGitOptionsForTarget(target)) return { ok: true } } @@ -230,7 +268,7 @@ export class RuntimeGitCommands { await provider.checkoutBranch(target.worktree.path, branch) return { ok: true, branch } } - await checkoutBranch(target.worktree.path, branch) + await checkoutBranch(target.worktree.path, branch, localGitOptionsForTarget(target)) return { ok: true, branch } } @@ -243,7 +281,7 @@ export class RuntimeGitCommands { } return provider.listLocalBranches(target.worktree.path) } - return listLocalBranches(target.worktree.path) + return listLocalBranches(target.worktree.path, localGitOptionsForTarget(target)) } async getRuntimeGitDiff( @@ -261,7 +299,13 @@ export class RuntimeGitCommands { } return provider.getDiff(target.worktree.path, relativePath, staged, compareAgainstHead) } - return getDiff(target.worktree.path, relativePath, staged, compareAgainstHead) + return getDiff( + target.worktree.path, + relativePath, + staged, + compareAgainstHead, + localGitOptionsForTarget(target) + ) } async getRuntimeGitBranchCompare( @@ -276,7 +320,7 @@ export class RuntimeGitCommands { } return provider.getBranchCompare(target.worktree.path, baseRef) } - return getBranchCompare(target.worktree.path, baseRef) + return getBranchCompare(target.worktree.path, baseRef, localGitOptionsForTarget(target)) } async getRuntimeGitCommitCompare( @@ -291,7 +335,7 @@ export class RuntimeGitCommands { } return provider.getCommitCompare(target.worktree.path, commitId) } - return getCommitCompare(target.worktree.path, commitId) + return getCommitCompare(target.worktree.path, commitId, localGitOptionsForTarget(target)) } async getRuntimeGitUpstreamStatus( @@ -306,7 +350,7 @@ export class RuntimeGitCommands { } return provider.getUpstreamStatus(target.worktree.path, pushTarget) } - return getUpstreamStatus(target.worktree.path, pushTarget) + return getUpstreamStatus(target.worktree.path, pushTarget, localGitOptionsForTarget(target)) } async fetchRuntimeGit( @@ -322,7 +366,7 @@ export class RuntimeGitCommands { await provider.fetchRemote(target.worktree.path, pushTarget) return { ok: true } } - await gitFetch(target.worktree.path, pushTarget) + await gitFetch(target.worktree.path, pushTarget, localGitOptionsForTarget(target)) return { ok: true } } @@ -338,7 +382,11 @@ export class RuntimeGitCommands { } return provider.syncForkDefaultBranch(target.worktree.path, expectedUpstream) } - return gitSyncForkDefaultBranch(target.worktree.path, expectedUpstream) + return gitSyncForkDefaultBranch( + target.worktree.path, + expectedUpstream, + localGitOptionsForTarget(target) + ) } async pullRuntimeGit( @@ -354,7 +402,7 @@ export class RuntimeGitCommands { await provider.pullBranch(target.worktree.path, pushTarget) return { ok: true } } - await gitPull(target.worktree.path, pushTarget) + await gitPull(target.worktree.path, pushTarget, localGitOptionsForTarget(target)) return { ok: true } } @@ -371,7 +419,7 @@ export class RuntimeGitCommands { await provider.fastForwardBranch(target.worktree.path, pushTarget) return { ok: true } } - await gitFastForward(target.worktree.path, pushTarget) + await gitFastForward(target.worktree.path, pushTarget, localGitOptionsForTarget(target)) return { ok: true } } @@ -385,7 +433,7 @@ export class RuntimeGitCommands { await provider.rebaseFromBase(target.worktree.path, baseRef) return { ok: true } } - await gitPullRebaseFromBase(target.worktree.path, baseRef) + await gitPullRebaseFromBase(target.worktree.path, baseRef, localGitOptionsForTarget(target)) return { ok: true } } @@ -407,7 +455,8 @@ export class RuntimeGitCommands { return { ok: true } } await gitPush(target.worktree.path, publish === true, pushTarget, { - forceWithLease: forceWithLease === true + forceWithLease: forceWithLease === true, + ...localGitOptionsForTarget(target) }) return { ok: true } } @@ -441,12 +490,16 @@ export class RuntimeGitCommands { } ) } - return getBranchDiff(target.worktree.path, { - mergeBase: compare.mergeBase, - headOid: compare.headOid, - filePath: relativePath, - oldPath: oldRelativePath - }) + return getBranchDiff( + target.worktree.path, + { + mergeBase: compare.mergeBase, + headOid: compare.headOid, + filePath: relativePath, + oldPath: oldRelativePath + }, + localGitOptionsForTarget(target) + ) } async getRuntimeGitCommitDiff( @@ -468,12 +521,16 @@ export class RuntimeGitCommands { oldPath: oldRelativePath }) } - return getCommitDiff(target.worktree.path, { - commitOid: args.commitOid, - parentOid: args.parentOid, - filePath: relativePath, - oldPath: oldRelativePath - }) + return getCommitDiff( + target.worktree.path, + { + commitOid: args.commitOid, + parentOid: args.parentOid, + filePath: relativePath, + oldPath: oldRelativePath + }, + localGitOptionsForTarget(target) + ) } async commitRuntimeGit( @@ -491,7 +548,7 @@ export class RuntimeGitCommands { } return provider.commit(target.worktree.path, message) } - return commitChanges(target.worktree.path, message) + return commitChanges(target.worktree.path, message, localGitOptionsForTarget(target)) } async generateRuntimeCommitMessage( @@ -547,7 +604,7 @@ export class RuntimeGitCommands { let context: CommitMessageDraftContext | null try { - context = await getStagedCommitContext(target.worktree.path) + context = await getStagedCommitContext(target.worktree.path, localGitOptionsForTarget(target)) } catch (error) { console.error('[runtime-git] Failed to read staged commit context:', error) return { success: false, error: 'Failed to read staged changes.' } @@ -557,16 +614,17 @@ export class RuntimeGitCommands { } const localEnv = await prepareLocalCommitMessageAgentEnv( resolvedSettings.params.agentId, - this.host.getCommitMessageAgentEnvironment?.() + this.host.getCommitMessageAgentEnvironment?.(), + localAgentRuntimeTargetForTarget(target) ) if (!localEnv.ok) { return { success: false, error: localEnv.error } } - return generateCommitMessageFromContext(context, resolvedSettings.params, { - kind: 'local', - cwd: target.worktree.path, - ...(localEnv.env ? { env: localEnv.env } : {}) - }) + return generateCommitMessageFromContext( + context, + resolvedSettings.params, + localTextGenerationTargetForTarget(target, localEnv.env) + ) } async cancelRuntimeGenerateCommitMessage(worktreeSelector: string): Promise<{ ok: true }> { @@ -636,7 +694,12 @@ export class RuntimeGitCommands { currentDraft: input.draft }) : await getPullRequestDraftContext( - (argv, options) => gitExecFileAsync(argv, { cwd: target.worktree.path, ...options }), + (argv, options) => + gitExecFileAsync(argv, { + cwd: target.worktree.path, + ...localGitOptionsForTarget(target), + ...options + }), { base: input.base, currentTitle: input.title, @@ -666,16 +729,17 @@ export class RuntimeGitCommands { const localEnv = await prepareLocalCommitMessageAgentEnv( resolvedSettings.params.agentId, - this.host.getCommitMessageAgentEnvironment?.() + this.host.getCommitMessageAgentEnvironment?.(), + localAgentRuntimeTargetForTarget(target) ) if (!localEnv.ok) { return { success: false, error: localEnv.error } } - return generatePullRequestFieldsFromContext(context, resolvedSettings.params, { - kind: 'local', - cwd: target.worktree.path, - ...(localEnv.env ? { env: localEnv.env } : {}) - }) + return generatePullRequestFieldsFromContext( + context, + resolvedSettings.params, + localTextGenerationTargetForTarget(target, localEnv.env) + ) } async cancelRuntimeGeneratePullRequestFields(worktreeSelector: string): Promise<{ ok: true }> { @@ -716,12 +780,19 @@ export class RuntimeGitCommands { } const localEnv = await prepareLocalCommitMessageAgentEnv( typedAgentId, - this.host.getCommitMessageAgentEnvironment?.() + this.host.getCommitMessageAgentEnvironment?.(), + localAgentRuntimeTargetForTarget(target) ) if (!localEnv.ok) { return { success: false, error: localEnv.error } } - return discoverCommitMessageModelsLocal(typedAgentId, localEnv.env, agentCommandOverride) + const localOptions = localGitOptionsForTarget(target) + return localOptions.wslDistro + ? discoverCommitMessageModelsLocal(typedAgentId, localEnv.env, agentCommandOverride, { + cwd: target.worktree.path, + wslDistro: localOptions.wslDistro + }) + : discoverCommitMessageModelsLocal(typedAgentId, localEnv.env, agentCommandOverride) } async stageRuntimeGitPath(worktreeSelector: string, filePath: string): Promise<{ ok: true }> { @@ -735,7 +806,7 @@ export class RuntimeGitCommands { await provider.stageFile(target.worktree.path, relativePath) return { ok: true } } - await stageFile(target.worktree.path, relativePath) + await stageFile(target.worktree.path, relativePath, localGitOptionsForTarget(target)) return { ok: true } } @@ -750,7 +821,7 @@ export class RuntimeGitCommands { await provider.unstageFile(target.worktree.path, relativePath) return { ok: true } } - await unstageFile(target.worktree.path, relativePath) + await unstageFile(target.worktree.path, relativePath, localGitOptionsForTarget(target)) return { ok: true } } @@ -768,7 +839,7 @@ export class RuntimeGitCommands { await provider.bulkStageFiles(target.worktree.path, relativePaths) return { ok: true } } - await bulkStageFiles(target.worktree.path, relativePaths) + await bulkStageFiles(target.worktree.path, relativePaths, localGitOptionsForTarget(target)) return { ok: true } } @@ -786,7 +857,7 @@ export class RuntimeGitCommands { await provider.bulkUnstageFiles(target.worktree.path, relativePaths) return { ok: true } } - await bulkUnstageFiles(target.worktree.path, relativePaths) + await bulkUnstageFiles(target.worktree.path, relativePaths, localGitOptionsForTarget(target)) return { ok: true } } @@ -804,7 +875,7 @@ export class RuntimeGitCommands { await provider.bulkDiscardChanges(target.worktree.path, relativePaths) return { ok: true } } - await bulkDiscardChanges(target.worktree.path, relativePaths) + await bulkDiscardChanges(target.worktree.path, relativePaths, localGitOptionsForTarget(target)) return { ok: true } } @@ -819,7 +890,7 @@ export class RuntimeGitCommands { await provider.discardChanges(target.worktree.path, relativePath) return { ok: true } } - await discardChanges(target.worktree.path, relativePath) + await discardChanges(target.worktree.path, relativePath, localGitOptionsForTarget(target)) return { ok: true } } diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 39fc7ca4d7d..9544d041075 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -57,6 +57,21 @@ import { RpcDispatcher } from './rpc/dispatcher' import type { RpcRequest } from './rpc/core' import { TERMINAL_METHODS } from './rpc/methods/terminal' +const ORIGINAL_PLATFORM_DESCRIPTOR = Object.getOwnPropertyDescriptor(process, 'platform') + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { + configurable: true, + value: platform + }) +} + +function resetPlatform(): void { + if (ORIGINAL_PLATFORM_DESCRIPTOR) { + Object.defineProperty(process, 'platform', ORIGINAL_PLATFORM_DESCRIPTOR) + } +} + const electronMocks = vi.hoisted(() => { type Listener = (...args: unknown[]) => void const listeners = new Map>() @@ -107,7 +122,35 @@ const { getHostedReviewCreationEligibilityMock, getHostedReviewForBranchMock, getPRForBranchMock, + getRepoSlugMock, + getRepoUpstreamMock, + getGitHubWorkItemMock, + getGitHubWorkItemByOwnerRepoMock, + getGitHubWorkItemDetailsMock, + getGitHubPRFileContentsMock, + getGitHubPRChecksMock, + rerunGitHubPRChecksMock, + getGitHubPRCheckDetailsMock, + getGitHubPRCommentsMock, + resolveGitHubReviewThreadMock, + setGitHubPRFileViewedMock, + updateGitHubPRTitleMock, + updateGitHubPRDetailsMock, + mergeGitHubPRMock, + setGitHubPRAutoMergeMock, + updateGitHubPRStateMock, + requestGitHubPRReviewersMock, + removeGitHubPRReviewersMock, + addGitHubPRReviewCommentMock, + addGitHubPRReviewCommentReplyMock, listGitHubIssuesMock, + listGitHubWorkItemsMock, + countGitHubWorkItemsMock, + createGitHubIssueMock, + updateGitHubIssueMock, + addGitHubIssueCommentMock, + listGitHubLabelsMock, + listGitHubAssignableUsersMock, detectInstalledAgentsMock, detectRemoteAgentsMock, listGitLabMergeRequestsMock, @@ -128,6 +171,7 @@ const { mergeGitLabMRMock, closeGitLabMRMock, reopenGitLabMRMock, + updateGitLabMRMock, getGlabKnownHostsMock, getGitLabWorkItemDetailsMock, updateGitLabMRReviewersMock, @@ -169,7 +213,35 @@ const { getHostedReviewCreationEligibilityMock: vi.fn(), getHostedReviewForBranchMock: vi.fn(), getPRForBranchMock: vi.fn().mockResolvedValue(null), + getRepoSlugMock: vi.fn().mockResolvedValue(null), + getRepoUpstreamMock: vi.fn().mockResolvedValue(null), + getGitHubWorkItemMock: vi.fn(), + getGitHubWorkItemByOwnerRepoMock: vi.fn(), + getGitHubWorkItemDetailsMock: vi.fn(), + getGitHubPRFileContentsMock: vi.fn(), + getGitHubPRChecksMock: vi.fn(), + rerunGitHubPRChecksMock: vi.fn(), + getGitHubPRCheckDetailsMock: vi.fn(), + getGitHubPRCommentsMock: vi.fn(), + resolveGitHubReviewThreadMock: vi.fn(), + setGitHubPRFileViewedMock: vi.fn(), + updateGitHubPRTitleMock: vi.fn(), + updateGitHubPRDetailsMock: vi.fn(), + mergeGitHubPRMock: vi.fn(), + setGitHubPRAutoMergeMock: vi.fn(), + updateGitHubPRStateMock: vi.fn(), + requestGitHubPRReviewersMock: vi.fn(), + removeGitHubPRReviewersMock: vi.fn(), + addGitHubPRReviewCommentMock: vi.fn(), + addGitHubPRReviewCommentReplyMock: vi.fn(), listGitHubIssuesMock: vi.fn(), + listGitHubWorkItemsMock: vi.fn(), + countGitHubWorkItemsMock: vi.fn(), + createGitHubIssueMock: vi.fn(), + updateGitHubIssueMock: vi.fn(), + addGitHubIssueCommentMock: vi.fn(), + listGitHubLabelsMock: vi.fn(), + listGitHubAssignableUsersMock: vi.fn(), detectInstalledAgentsMock: vi.fn(), detectRemoteAgentsMock: vi.fn(), listGitLabMergeRequestsMock: vi.fn(), @@ -190,6 +262,7 @@ const { mergeGitLabMRMock: vi.fn(), closeGitLabMRMock: vi.fn(), reopenGitLabMRMock: vi.fn(), + updateGitLabMRMock: vi.fn(), getGlabKnownHostsMock: vi.fn(), getGitLabWorkItemDetailsMock: vi.fn(), updateGitLabMRReviewersMock: vi.fn(), @@ -293,7 +366,34 @@ vi.mock('../github/client', async (importOriginal) => { return { ...actual, getPRForBranch: getPRForBranchMock, - listIssues: listGitHubIssuesMock + getRepoSlug: getRepoSlugMock, + getRepoUpstream: getRepoUpstreamMock, + getWorkItem: getGitHubWorkItemMock, + getWorkItemByOwnerRepo: getGitHubWorkItemByOwnerRepoMock, + getPRChecks: getGitHubPRChecksMock, + rerunPRChecks: rerunGitHubPRChecksMock, + getPRCheckDetails: getGitHubPRCheckDetailsMock, + getPRComments: getGitHubPRCommentsMock, + resolveReviewThread: resolveGitHubReviewThreadMock, + setPRFileViewed: setGitHubPRFileViewedMock, + updatePRTitle: updateGitHubPRTitleMock, + updatePRDetails: updateGitHubPRDetailsMock, + mergePR: mergeGitHubPRMock, + setPRAutoMerge: setGitHubPRAutoMergeMock, + updatePRState: updateGitHubPRStateMock, + requestPRReviewers: requestGitHubPRReviewersMock, + removePRReviewers: removeGitHubPRReviewersMock, + addPRReviewComment: addGitHubPRReviewCommentMock, + addPRReviewCommentReply: addGitHubPRReviewCommentReplyMock, + listIssues: listGitHubIssuesMock, + listWorkItems: listGitHubWorkItemsMock, + countWorkItems: countGitHubWorkItemsMock, + getIssue: getIssueMock, + createIssue: createGitHubIssueMock, + updateIssue: updateGitHubIssueMock, + addIssueComment: addGitHubIssueCommentMock, + listLabels: listGitHubLabelsMock, + listAssignableUsers: listGitHubAssignableUsersMock } }) @@ -319,6 +419,7 @@ vi.mock('../gitlab/client', async (importOriginal) => { mergeMR: mergeGitLabMRMock, closeMR: closeGitLabMRMock, reopenMR: reopenGitLabMRMock, + updateMR: updateGitLabMRMock, updateMRReviewers: updateGitLabMRReviewersMock } }) @@ -339,6 +440,15 @@ vi.mock('../gitlab/work-item-details', async (importOriginal) => { } }) +vi.mock('../github/work-item-details', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { + ...actual, + getWorkItemDetails: getGitHubWorkItemDetailsMock, + getPRFileContents: getGitHubPRFileContentsMock + } +}) + vi.mock('../github/issues', async (importOriginal) => { const actual = (await importOriginal()) as Record return { @@ -363,6 +473,7 @@ vi.mock('../git/repo', async (importOriginal) => { }) afterEach(() => { + resetPlatform() advertisedUrlWatcher.clear() electronMocks.BrowserWindow.fromId.mockReset() electronMocks.BrowserWindow.fromId.mockReturnValue(null) @@ -436,8 +547,64 @@ afterEach(() => { getHostedReviewForBranchMock.mockResolvedValue(null) getPRForBranchMock.mockReset() getPRForBranchMock.mockResolvedValue(null) + getRepoSlugMock.mockReset() + getRepoSlugMock.mockResolvedValue(null) + getRepoUpstreamMock.mockReset() + getRepoUpstreamMock.mockResolvedValue(null) + getGitHubWorkItemMock.mockReset() + getGitHubWorkItemMock.mockResolvedValue(null) + getGitHubWorkItemByOwnerRepoMock.mockReset() + getGitHubWorkItemByOwnerRepoMock.mockResolvedValue(null) + getGitHubWorkItemDetailsMock.mockReset() + getGitHubWorkItemDetailsMock.mockResolvedValue(null) + getGitHubPRFileContentsMock.mockReset() + getGitHubPRFileContentsMock.mockResolvedValue({ original: '', modified: '' }) + getGitHubPRChecksMock.mockReset() + getGitHubPRChecksMock.mockResolvedValue([]) + rerunGitHubPRChecksMock.mockReset() + rerunGitHubPRChecksMock.mockResolvedValue({ ok: true, count: 0 }) + getGitHubPRCheckDetailsMock.mockReset() + getGitHubPRCheckDetailsMock.mockResolvedValue(null) + getGitHubPRCommentsMock.mockReset() + getGitHubPRCommentsMock.mockResolvedValue([]) + resolveGitHubReviewThreadMock.mockReset() + resolveGitHubReviewThreadMock.mockResolvedValue(true) + setGitHubPRFileViewedMock.mockReset() + setGitHubPRFileViewedMock.mockResolvedValue(true) + updateGitHubPRTitleMock.mockReset() + updateGitHubPRTitleMock.mockResolvedValue(true) + updateGitHubPRDetailsMock.mockReset() + updateGitHubPRDetailsMock.mockResolvedValue({ ok: true }) + mergeGitHubPRMock.mockReset() + mergeGitHubPRMock.mockResolvedValue({ ok: true }) + setGitHubPRAutoMergeMock.mockReset() + setGitHubPRAutoMergeMock.mockResolvedValue({ ok: true }) + updateGitHubPRStateMock.mockReset() + updateGitHubPRStateMock.mockResolvedValue({ ok: true }) + requestGitHubPRReviewersMock.mockReset() + requestGitHubPRReviewersMock.mockResolvedValue({ ok: true }) + removeGitHubPRReviewersMock.mockReset() + removeGitHubPRReviewersMock.mockResolvedValue({ ok: true }) + addGitHubPRReviewCommentMock.mockReset() + addGitHubPRReviewCommentMock.mockResolvedValue({ ok: true }) + addGitHubPRReviewCommentReplyMock.mockReset() + addGitHubPRReviewCommentReplyMock.mockResolvedValue({ ok: true }) listGitHubIssuesMock.mockReset() listGitHubIssuesMock.mockResolvedValue({ items: [] }) + listGitHubWorkItemsMock.mockReset() + listGitHubWorkItemsMock.mockResolvedValue({ items: [] }) + countGitHubWorkItemsMock.mockReset() + countGitHubWorkItemsMock.mockResolvedValue(0) + createGitHubIssueMock.mockReset() + createGitHubIssueMock.mockResolvedValue({ ok: true, number: 1, url: 'https://example.com/1' }) + updateGitHubIssueMock.mockReset() + updateGitHubIssueMock.mockResolvedValue({ ok: true }) + addGitHubIssueCommentMock.mockReset() + addGitHubIssueCommentMock.mockResolvedValue({ ok: true }) + listGitHubLabelsMock.mockReset() + listGitHubLabelsMock.mockResolvedValue([]) + listGitHubAssignableUsersMock.mockReset() + listGitHubAssignableUsersMock.mockResolvedValue([]) detectInstalledAgentsMock.mockReset() detectInstalledAgentsMock.mockResolvedValue([]) detectRemoteAgentsMock.mockReset() @@ -484,6 +651,8 @@ afterEach(() => { closeGitLabMRMock.mockResolvedValue({ ok: true }) reopenGitLabMRMock.mockReset() reopenGitLabMRMock.mockResolvedValue({ ok: true }) + updateGitLabMRMock.mockReset() + updateGitLabMRMock.mockResolvedValue({ ok: true }) getGitLabWorkItemDetailsMock.mockReset() getGitLabWorkItemDetailsMock.mockResolvedValue({ body: 'Details' }) updateGitLabMRReviewersMock.mockReset() @@ -647,6 +816,21 @@ function createRuntime(): OrcaRuntimeService { return new OrcaRuntimeService(store) } +async function withPlatform(platform: NodeJS.Platform, run: () => Promise): Promise { + const original = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { + configurable: true, + value: platform + }) + try { + return await run() + } finally { + if (original) { + Object.defineProperty(process, 'platform', original) + } + } +} + function makeFolderProjectGroup(overrides: Partial = {}): ProjectGroup { return { id: TEST_FOLDER_PROJECT_GROUP_ID, @@ -801,7 +985,8 @@ const store = { refreshLocalBaseRefOnWorktreeCreate: false, branchPrefix: 'none', branchPrefixCustom: '' - }) + }), + getProjects: () => [] } function makeHeadlessTerminalLayout( @@ -3462,6 +3647,378 @@ describe('OrcaRuntimeService', () => { expect(listGitHubIssuesMock).toHaveBeenCalledWith('/remote/repo', 10, undefined, 'ssh-1') }) + it('routes runtime GitHub repo identity helpers through the selected WSL project runtime', async () => { + setPlatform('win32') + const runtimeStore = { + ...store, + getProjects: () => [ + { + id: 'project-1', + displayName: 'repo', + badgeColor: 'blue', + sourceRepoIds: [TEST_REPO_ID], + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }, + createdAt: 0, + updatedAt: 0 + } + ], + getSettings: () => ({ + ...store.getSettings(), + localWindowsRuntimeDefault: { kind: 'windows-host' } + }) + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + getRepoSlugMock.mockResolvedValueOnce({ owner: 'acme', repo: 'orca' }) + getRepoUpstreamMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) + + await expect(runtime.getRepoSlug('id:repo-1')).resolves.toEqual({ + owner: 'acme', + repo: 'orca' + }) + await expect(runtime.getRepoUpstream('id:repo-1')).resolves.toEqual({ + owner: 'stablyai', + repo: 'orca' + }) + + const runtimeOptions = { localGitExecOptions: { wslDistro: 'Ubuntu' } } + expect(getRepoSlugMock).toHaveBeenCalledWith(TEST_REPO_PATH, null, runtimeOptions) + expect(getRepoUpstreamMock).toHaveBeenCalledWith(TEST_REPO_PATH, null, runtimeOptions) + }) + + it('routes runtime GitHub issue and work-item actions through the selected WSL project runtime', async () => { + setPlatform('win32') + const runtimeStore = { + ...store, + getProjects: () => [ + { + id: 'project-1', + displayName: 'repo', + badgeColor: 'blue', + sourceRepoIds: [TEST_REPO_ID], + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }, + createdAt: 0, + updatedAt: 0 + } + ], + getSettings: () => ({ + ...store.getSettings(), + localWindowsRuntimeDefault: { kind: 'windows-host' } + }) + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + const localGitOptions = { wslDistro: 'Ubuntu' } + const issueFields = { labels: ['bug'], assignees: ['octo'] } + const issueUpdates = { body: 'Updated body' } + listGitHubWorkItemsMock.mockResolvedValueOnce({ items: [] }) + countGitHubWorkItemsMock.mockResolvedValueOnce(0) + listGitHubIssuesMock.mockResolvedValueOnce({ items: [] }) + getIssueMock.mockResolvedValueOnce(null) + createGitHubIssueMock.mockResolvedValueOnce({ + ok: true, + number: 12, + url: 'https://github.com/acme/orca/issues/12' + }) + updateGitHubIssueMock.mockResolvedValueOnce({ ok: true }) + addGitHubIssueCommentMock.mockResolvedValueOnce({ ok: true }) + listGitHubLabelsMock.mockResolvedValueOnce([]) + listGitHubAssignableUsersMock.mockResolvedValueOnce([]) + + await runtime.listRepoWorkItems('id:repo-1', 7, 'is:open', 'cursor', true) + await runtime.countRepoWorkItems('id:repo-1', 'is:issue') + await runtime.listRepoIssues('id:repo-1', 5) + await runtime.getRepoIssue('id:repo-1', 12) + await runtime.createRepoIssue('id:repo-1', 'Title', 'Body', issueFields) + await runtime.updateRepoIssue('id:repo-1', 12, issueUpdates) + await runtime.addRepoIssueComment('id:repo-1', 12, 'Comment') + await runtime.listRepoLabels('id:repo-1') + await runtime.listRepoAssignableUsers('id:repo-1') + + expect(listGitHubWorkItemsMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + 7, + 'is:open', + 'cursor', + undefined, + null, + true, + localGitOptions + ) + expect(countGitHubWorkItemsMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + 'is:issue', + undefined, + null, + localGitOptions + ) + expect(listGitHubIssuesMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + 5, + undefined, + null, + localGitOptions + ) + expect(getIssueMock).toHaveBeenCalledWith(TEST_REPO_PATH, 12, null, localGitOptions) + expect(createGitHubIssueMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + 'Title', + 'Body', + undefined, + null, + issueFields, + localGitOptions + ) + expect(updateGitHubIssueMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + 12, + issueUpdates, + null, + localGitOptions + ) + expect(addGitHubIssueCommentMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + 12, + 'Comment', + null, + null, + localGitOptions + ) + expect(listGitHubLabelsMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + undefined, + null, + localGitOptions + ) + expect(listGitHubAssignableUsersMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + undefined, + null, + localGitOptions + ) + }) + + it('routes runtime GitHub PR details and actions through the selected WSL project runtime', async () => { + setPlatform('win32') + const runtimeStore = { + ...store, + getProjects: () => [ + { + id: 'project-1', + displayName: 'repo', + badgeColor: 'blue', + sourceRepoIds: [TEST_REPO_ID], + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }, + createdAt: 0, + updatedAt: 0 + } + ], + getSettings: () => ({ + ...store.getSettings(), + localWindowsRuntimeDefault: { kind: 'windows-host' } + }) + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + const localGitOptions = { wslDistro: 'Ubuntu' } + const prRepo = { owner: 'acme', repo: 'orca' } + + await runtime.getRepoPRForBranch('id:repo-1', 'feature/wsl', 42, 43) + await runtime.getRepoWorkItem('id:repo-1', 42, 'pr') + await runtime.getRepoWorkItemByOwnerRepo('id:repo-1', prRepo, 42, 'pr') + await runtime.getRepoWorkItemDetails('id:repo-1', 42, 'pr') + await runtime.getRepoPRChecks('id:repo-1', 42, 'head-sha', prRepo, { noCache: true }) + await runtime.rerunRepoPRChecks('id:repo-1', 42, { headSha: 'head-sha', failedOnly: true }) + await runtime.getRepoPRCheckDetails('id:repo-1', { + checkRunId: 9, + workflowRunId: 8, + checkName: 'lint', + url: 'https://example.com/check', + prRepo + }) + await runtime.getRepoPRComments('id:repo-1', 42, prRepo, { noCache: true }) + await runtime.getRepoPRFileContents('id:repo-1', { + prNumber: 42, + path: 'src/app.ts', + status: 'modified', + headSha: 'head-sha', + baseSha: 'base-sha' + }) + await runtime.resolveRepoReviewThread('id:repo-1', 'thread-1', true) + await runtime.setRepoPRFileViewed('id:repo-1', { + pullRequestId: 'PR_kw', + path: 'src/app.ts', + viewed: true + }) + await runtime.updateRepoPRTitle('id:repo-1', 42, 'New title', prRepo) + await runtime.updateRepoPRDetails('id:repo-1', 42, { body: 'New body' }, prRepo) + await runtime.mergeRepoPR('id:repo-1', 42, 'squash', prRepo) + await runtime.setRepoPRAutoMerge('id:repo-1', 42, true, 'squash', prRepo) + await runtime.updateRepoPRState('id:repo-1', 42, { state: 'closed' }) + await runtime.requestRepoPRReviewers('id:repo-1', 42, ['octo']) + await runtime.removeRepoPRReviewers('id:repo-1', 42, ['octo']) + await runtime.addRepoPRReviewComment('id:repo-1', { + prNumber: 42, + body: 'Inline', + commitId: 'head-sha', + path: 'src/app.ts', + line: 10 + }) + await runtime.addRepoPRReviewCommentReply('id:repo-1', { + prNumber: 42, + commentId: 11, + body: 'Reply', + threadId: 'thread-1', + path: 'src/app.ts', + line: 10, + prRepo + }) + + expect(getPRForBranchMock).toHaveBeenCalledWith(TEST_REPO_PATH, 'feature/wsl', 42, null, null, { + localGitExecOptions: localGitOptions + }) + expect(getGitHubWorkItemMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + 42, + 'pr', + null, + localGitOptions + ) + expect(getGitHubWorkItemByOwnerRepoMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + prRepo, + 42, + 'pr', + null, + localGitOptions + ) + expect(getGitHubWorkItemDetailsMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + 42, + 'pr', + null, + localGitOptions + ) + expect(getGitHubPRChecksMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + 42, + 'head-sha', + prRepo, + { noCache: true }, + null, + localGitOptions + ) + expect(rerunGitHubPRChecksMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + 42, + { headSha: 'head-sha', failedOnly: true }, + null, + localGitOptions + ) + expect(getGitHubPRCheckDetailsMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + { + checkRunId: 9, + workflowRunId: 8, + checkName: 'lint', + url: 'https://example.com/check', + prRepo + }, + null, + localGitOptions + ) + expect(getGitHubPRCommentsMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + 42, + { noCache: true, prRepo }, + null, + localGitOptions + ) + expect(getGitHubPRFileContentsMock).toHaveBeenCalledWith( + expect.objectContaining({ repoPath: TEST_REPO_PATH, localGitOptions }) + ) + expect(resolveGitHubReviewThreadMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + 'thread-1', + true, + null, + localGitOptions + ) + expect(setGitHubPRFileViewedMock).toHaveBeenCalledWith( + expect.objectContaining({ repoPath: TEST_REPO_PATH, localGitOptions }) + ) + expect(updateGitHubPRTitleMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + 42, + 'New title', + null, + prRepo, + localGitOptions + ) + expect(updateGitHubPRDetailsMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + 42, + { body: 'New body' }, + null, + prRepo, + localGitOptions + ) + expect(mergeGitHubPRMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + 42, + 'squash', + null, + prRepo, + localGitOptions + ) + expect(setGitHubPRAutoMergeMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + 42, + true, + 'squash', + null, + prRepo, + localGitOptions + ) + expect(updateGitHubPRStateMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + 42, + { state: 'closed' }, + null, + localGitOptions + ) + expect(requestGitHubPRReviewersMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + 42, + ['octo'], + null, + localGitOptions + ) + expect(removeGitHubPRReviewersMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + 42, + ['octo'], + null, + localGitOptions + ) + expect(addGitHubPRReviewCommentMock).toHaveBeenCalledWith( + expect.objectContaining({ + repoPath: TEST_REPO_PATH, + localGitOptions, + body: 'Inline' + }) + ) + expect(addGitHubPRReviewCommentReplyMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + 42, + 11, + 'Reply', + 'thread-1', + 'src/app.ts', + 10, + null, + prRepo, + localGitOptions + ) + }) + it('rejects hosted review worktree selectors outside the selected repo', async () => { vi.mocked(listWorktrees).mockImplementation(async (repoPath: string) => { if (repoPath === '/tmp/repo-b') { @@ -3582,6 +4139,96 @@ describe('OrcaRuntimeService', () => { ) }) + it('routes local WSL project hosted review flows through runtime git options', async () => { + setPlatform('win32') + const wslStore = { + ...store, + getProjects: () => [ + { + id: 'project-1', + displayName: 'repo', + badgeColor: 'blue', + sourceRepoIds: [TEST_REPO_ID], + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }, + createdAt: 0, + updatedAt: 0 + } + ], + getSettings: () => ({ + ...store.getSettings(), + localWindowsRuntimeDefault: { kind: 'windows-host' } + }) + } + const runtime = new OrcaRuntimeService(wslStore as never) + getHostedReviewForBranchMock.mockResolvedValueOnce({ + provider: 'github', + number: 76, + title: 'Feature WSL', + state: 'open', + url: 'https://github.com/acme/orca/pull/76', + status: 'success', + updatedAt: '2026-06-16T00:00:00.000Z', + mergeable: 'MERGEABLE' + }) + createHostedReviewMock.mockResolvedValueOnce({ + ok: true, + number: 77, + url: 'https://github.com/acme/orca/pull/77' + }) + + await runtime.getHostedReviewForBranch({ + repoSelector: `id:${TEST_REPO_ID}`, + branch: 'feature/wsl', + linkedGitHubPR: 76 + }) + await runtime.getHostedReviewCreationEligibility({ + repoSelector: `id:${TEST_REPO_ID}`, + branch: 'feature/wsl', + base: 'main', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + await runtime.createHostedReview({ + repoSelector: `id:${TEST_REPO_ID}`, + provider: 'github', + base: 'main', + head: 'feature/wsl', + title: 'Feature WSL', + body: '', + draft: false + }) + + expect(getHostedReviewCreationEligibilityMock).toHaveBeenCalledWith( + expect.objectContaining({ + repoPath: TEST_REPO_PATH, + connectionId: null, + branch: 'feature/wsl', + localGitExecOptions: { wslDistro: 'Ubuntu' } + }) + ) + expect(getHostedReviewForBranchMock).toHaveBeenCalledWith( + expect.objectContaining({ + repoPath: TEST_REPO_PATH, + connectionId: null, + branch: 'feature/wsl', + linkedGitHubPR: 76, + localGitExecOptions: { wslDistro: 'Ubuntu' } + }) + ) + expect(createHostedReviewMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + expect.objectContaining({ + provider: 'github', + head: 'feature/wsl', + title: 'Feature WSL' + }), + null, + { localGitExecOptions: { wslDistro: 'Ubuntu' } } + ) + }) + it('treats SSH worktree drift as unknown without local git probes', async () => { vi.mocked(listWorktrees).mockClear() vi.mocked(getDefaultBaseRef).mockClear() @@ -3624,6 +4271,87 @@ describe('OrcaRuntimeService', () => { expect(listWorktrees).not.toHaveBeenCalled() }) + it('routes local WSL project worktree drift probes through runtime git options', async () => { + setPlatform('win32') + const runtimeStore = { + ...store, + getProjects: () => [ + { + id: 'project-1', + displayName: 'repo', + badgeColor: 'blue', + sourceRepoIds: [TEST_REPO_ID], + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }, + createdAt: 0, + updatedAt: 0 + } + ], + getSettings: () => ({ + ...store.getSettings(), + localWindowsRuntimeDefault: { kind: 'windows-host' } + }) + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + const wslGitOptions = { cwd: TEST_REPO_PATH, wslDistro: 'Ubuntu' } + const asyncGitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => { + if (args[0] === 'symbolic-ref') { + return { stdout: 'refs/remotes/origin/main\n', stderr: '' } + } + if (args[0] === 'remote') { + return { stdout: 'origin\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('--git-common-dir')) { + return { stdout: `${TEST_REPO_PATH}/.git\n`, stderr: '' } + } + if (args[0] === 'fetch') { + return { stdout: '', stderr: '' } + } + throw new Error(`unexpected git call: ${args.join(' ')}`) + }) + const syncGitSpy = vi + .spyOn(gitRunner, 'gitExecFileSync') + .mockImplementation((args: string[]) => { + if (args[0] === 'rev-list') { + return '1\t2\n' + } + if (args[0] === 'log') { + return 'base commit 2\nbase commit 1\n' + } + throw new Error(`unexpected sync git call: ${args.join(' ')}`) + }) + + try { + const result = await runtime.probeWorktreeDrift(`id:${TEST_WORKTREE_ID}`) + + expect(result).toEqual({ + base: 'origin/main', + behind: 2, + recentSubjects: ['base commit 2', 'base commit 1'] + }) + expect(asyncGitSpy).toHaveBeenCalledWith( + ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], + wslGitOptions + ) + expect(asyncGitSpy).toHaveBeenCalledWith(['remote'], wslGitOptions) + expect(asyncGitSpy).toHaveBeenCalledWith( + ['rev-parse', '--path-format=absolute', '--git-common-dir'], + wslGitOptions + ) + expect(asyncGitSpy).toHaveBeenCalledWith(['fetch', 'origin'], wslGitOptions) + expect(syncGitSpy).toHaveBeenCalledWith( + ['rev-list', '--left-right', '--count', 'HEAD...origin/main'], + { cwd: TEST_WORKTREE_PATH, wslDistro: 'Ubuntu' } + ) + expect(syncGitSpy).toHaveBeenCalledWith( + ['log', '--format=%s', '-n', '5', 'HEAD..origin/main'], + { cwd: TEST_WORKTREE_PATH, wslDistro: 'Ubuntu' } + ) + } finally { + asyncGitSpy.mockRestore() + syncGitSpy.mockRestore() + } + }) + it('deduplicates runtime repo paths with Windows/UNC comparison semantics', async () => { const added: Record[] = [] const uncStore = { @@ -10716,6 +11444,98 @@ describe('OrcaRuntimeService', () => { ) }) + it('uses POSIX quoting for mobile agent launch commands in WSL project runtimes', async () => { + await withPlatform('win32', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-agent' }) + const runtime = new OrcaRuntimeService({ + ...store, + getProjects: () => [ + { + id: 'project-1', + displayName: 'repo', + badgeColor: 'blue', + sourceRepoIds: [TEST_REPO_ID], + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }, + createdAt: 0, + updatedAt: 0 + } + ], + getSettings: () => ({ + ...store.getSettings(), + disabledTuiAgents: [], + agentCmdOverrides: { 'command-code': 'command-code --profile mobile' }, + agentDefaultArgs: { 'command-code': '--note "can\'t"' }, + localWindowsRuntimeDefault: { kind: 'windows-host' } + }) + } as never) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + + await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + agent: 'command-code' + }) + + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + command: "command-code --profile mobile '--note' 'can'\\''t'", + cwd: TEST_WORKTREE_PATH, + worktreeId: TEST_WORKTREE_ID + }) + ) + }) + }) + + it('keeps PowerShell quoting for mobile agent launch commands in Windows host runtimes', async () => { + await withPlatform('win32', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-agent' }) + const runtime = new OrcaRuntimeService({ + ...store, + getProjects: () => [ + { + id: 'project-1', + displayName: 'repo', + badgeColor: 'blue', + sourceRepoIds: [TEST_REPO_ID], + localWindowsRuntimePreference: { kind: 'windows-host' }, + createdAt: 0, + updatedAt: 0 + } + ], + getSettings: () => ({ + ...store.getSettings(), + disabledTuiAgents: [], + agentCmdOverrides: { 'command-code': 'command-code --profile mobile' }, + agentDefaultArgs: { 'command-code': '--note "can\'t"' }, + localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } + }) + } as never) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + + await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + agent: 'command-code' + }) + + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + command: "command-code --profile mobile '--note' 'can''t'", + cwd: TEST_WORKTREE_PATH, + worktreeId: TEST_WORKTREE_ID + }) + ) + }) + }) + it('publishes headless mobile session agent identity with synthesized PTY status', async () => { const spawn = vi.fn().mockResolvedValue({ id: 'pty-agent' }) const runtime = new OrcaRuntimeService({ @@ -13682,7 +14502,8 @@ describe('OrcaRuntimeService', () => { expect(createSetupRunnerScript).toHaveBeenCalledWith( expect.objectContaining({ id: 'repo-1', path: '/tmp/repo' }), '/tmp/workspaces/runtime-hook-test', - 'pnpm worktree:setup' + 'pnpm worktree:setup', + undefined ) expect(runHook).not.toHaveBeenCalled() expect(addWorktree).toHaveBeenCalledWith( @@ -13836,7 +14657,8 @@ describe('OrcaRuntimeService', () => { expect(createSetupRunnerScript).toHaveBeenCalledWith( expect.objectContaining({ id: 'repo-1', path: '/tmp/repo' }), '/tmp/workspaces/runtime-hook-skip', - 'pnpm worktree:setup' + 'pnpm worktree:setup', + undefined ) expect(runHook).not.toHaveBeenCalled() expect(result).toEqual({ @@ -15317,6 +16139,281 @@ describe('OrcaRuntimeService', () => { ) }) + it('routes runtime GitLab issue, MR, work-item, and todo actions through the selected WSL project runtime', async () => { + setPlatform('win32') + const runtimeStore = { + ...store, + getProjects: () => [ + { + id: 'project-1', + displayName: 'repo', + badgeColor: 'blue', + sourceRepoIds: [TEST_REPO_ID], + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }, + createdAt: 0, + updatedAt: 0 + } + ], + getSettings: () => ({ + ...store.getSettings(), + localWindowsRuntimeDefault: { kind: 'windows-host' } + }) + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + const localGitOptions = { wslDistro: 'Ubuntu' } + listGitLabMergeRequestsMock.mockResolvedValue({ items: [] }) + listGitLabWorkItemsMock.mockResolvedValue({ items: [] }) + listGitLabIssuesMock.mockResolvedValue({ items: [] }) + listGitLabTodosMock.mockResolvedValue([]) + listGitLabLabelsMock.mockResolvedValue([]) + createGitLabIssueMock.mockResolvedValue({ + ok: true, + number: 7, + url: 'https://gitlab.example/issues/7' + }) + updateGitLabIssueMock.mockResolvedValue({ ok: true }) + addGitLabIssueCommentMock.mockResolvedValue({ ok: true }) + + await runtime.listGitLabRepoMRs(TEST_REPO_ID, 'opened', 1, 20) + await runtime.listGitLabRepoWorkItems(TEST_REPO_ID, 'opened', 1, 20) + await runtime.listGitLabRepoIssues(TEST_REPO_ID, 'opened', undefined, 20) + await runtime.listGitLabRepoTodos(TEST_REPO_ID) + await runtime.listGitLabRepoLabels(TEST_REPO_ID) + await runtime.createGitLabRepoIssue(TEST_REPO_ID, 'Title', 'Body') + await runtime.updateGitLabRepoIssue(TEST_REPO_ID, 7, { body: 'Updated' }) + await runtime.addGitLabRepoIssueComment(TEST_REPO_ID, 7, 'Comment') + + expect(listGitLabMergeRequestsMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + 'opened', + 1, + 20, + undefined, + undefined, + null, + localGitOptions + ) + expect(listGitLabWorkItemsMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + 'opened', + 1, + 20, + undefined, + undefined, + null, + localGitOptions + ) + expect(listGitLabIssuesMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + 20, + undefined, + 'opened', + undefined, + null, + localGitOptions + ) + expect(listGitLabTodosMock).toHaveBeenCalledWith(TEST_REPO_PATH, null, localGitOptions) + expect(listGitLabLabelsMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + undefined, + null, + localGitOptions + ) + expect(createGitLabIssueMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + 'Title', + 'Body', + undefined, + null, + localGitOptions + ) + expect(updateGitLabIssueMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + 7, + { body: 'Updated' }, + undefined, + null, + undefined, + localGitOptions + ) + expect(addGitLabIssueCommentMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + 7, + 'Comment', + undefined, + null, + undefined, + localGitOptions + ) + }) + + it('routes runtime GitLab MR details, review-management, job, and pasted URL actions through the selected WSL project runtime', async () => { + setPlatform('win32') + const runtimeStore = { + ...store, + getProjects: () => [ + { + id: 'project-1', + displayName: 'repo', + badgeColor: 'blue', + sourceRepoIds: [TEST_REPO_ID], + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }, + createdAt: 0, + updatedAt: 0 + } + ], + getSettings: () => ({ + ...store.getSettings(), + localWindowsRuntimeDefault: { kind: 'windows-host' } + }) + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + const localGitOptions = { wslDistro: 'Ubuntu' } + const inlineInput = { + body: 'Inline', + path: 'src/app.ts', + line: 12, + baseSha: 'base', + startSha: 'start', + headSha: 'head' + } + getGitLabWorkItemDetailsMock.mockResolvedValue({ body: 'Details' }) + updateGitLabMRMock.mockResolvedValue({ ok: true }) + updateGitLabMRReviewersMock.mockResolvedValue({ ok: true, reviewers: [] }) + addGitLabMRCommentMock.mockResolvedValue({ ok: true }) + addGitLabMRInlineCommentMock.mockResolvedValue({ ok: true }) + resolveGitLabMRDiscussionMock.mockResolvedValue({ ok: true }) + getGitLabJobTraceMock.mockResolvedValue({ ok: true, trace: 'trace' }) + retryGitLabJobMock.mockResolvedValue({ ok: true }) + mergeGitLabMRMock.mockResolvedValue({ ok: true }) + closeGitLabMRMock.mockResolvedValue({ ok: true }) + reopenGitLabMRMock.mockResolvedValue({ ok: true }) + getGitLabWorkItemByProjectRefMock.mockResolvedValue({ type: 'mr', number: 8 }) + + await runtime.getGitLabRepoWorkItemDetails(TEST_REPO_ID, 8, 'mr') + await runtime.updateGitLabRepoMR(TEST_REPO_ID, 8, { title: 'Renamed' }) + await runtime.updateGitLabRepoMRReviewers(TEST_REPO_ID, 8, [1]) + await runtime.addGitLabRepoMRComment(TEST_REPO_ID, 8, 'Comment') + await runtime.addGitLabRepoMRInlineComment(TEST_REPO_ID, 8, inlineInput) + await runtime.resolveGitLabRepoMRDiscussion(TEST_REPO_ID, 8, 'discussion-1', true) + await runtime.getGitLabRepoJobTrace(TEST_REPO_ID, 99) + await runtime.retryGitLabRepoJob(TEST_REPO_ID, 99) + await runtime.mergeGitLabRepoMR(TEST_REPO_ID, 8, 'squash') + await runtime.updateGitLabRepoMRState(TEST_REPO_ID, 8, 'closed') + await runtime.updateGitLabRepoMRState(TEST_REPO_ID, 8, 'opened') + await runtime.getGitLabRepoWorkItemByPath( + TEST_REPO_ID, + { host: 'gitlab.com', path: 'g/p' }, + 8, + 'mr' + ) + + expect(getGitLabWorkItemDetailsMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + 8, + 'mr', + undefined, + null, + undefined, + localGitOptions + ) + expect(updateGitLabMRMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + 8, + { title: 'Renamed' }, + undefined, + null, + undefined, + localGitOptions + ) + expect(updateGitLabMRReviewersMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + 8, + [1], + undefined, + null, + undefined, + localGitOptions + ) + expect(addGitLabMRCommentMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + 8, + 'Comment', + undefined, + null, + undefined, + localGitOptions + ) + expect(addGitLabMRInlineCommentMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + 8, + inlineInput, + undefined, + null, + undefined, + localGitOptions + ) + expect(resolveGitLabMRDiscussionMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + 8, + 'discussion-1', + true, + undefined, + null, + undefined, + localGitOptions + ) + expect(getGitLabJobTraceMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + 99, + undefined, + null, + undefined, + localGitOptions + ) + expect(retryGitLabJobMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + 99, + undefined, + null, + undefined, + localGitOptions + ) + expect(mergeGitLabMRMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + 8, + 'squash', + undefined, + null, + undefined, + localGitOptions + ) + expect(closeGitLabMRMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + 8, + undefined, + null, + undefined, + localGitOptions + ) + expect(reopenGitLabMRMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + 8, + undefined, + null, + undefined, + localGitOptions + ) + expect(getGitLabWorkItemByProjectRefMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + { host: 'gitlab.com', path: 'g/p' }, + 8, + 'mr', + null, + localGitOptions + ) + }) + it('normalizes runtime GitLab issue list arguments like the desktop IPC path', async () => { const runtime = new OrcaRuntimeService(store as never) @@ -15408,6 +16505,81 @@ describe('OrcaRuntimeService', () => { expect(updateSettings).not.toHaveBeenCalled() }) + it('routes runtime GitHub PR base git calls through the selected WSL project runtime', async () => { + setPlatform('win32') + const runtimeStore = { + ...store, + getProjects: () => [ + { + id: 'project-1', + displayName: 'repo', + badgeColor: 'blue', + sourceRepoIds: [TEST_REPO_ID], + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }, + createdAt: 0, + updatedAt: 0 + } + ], + getSettings: () => ({ + ...store.getSettings(), + localWindowsRuntimeDefault: { kind: 'windows-host' } + }) + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => { + if (args[0] === 'symbolic-ref') { + return { stdout: 'refs/remotes/origin/main\n', stderr: '' } + } + if (args[0] === 'config') { + return { stdout: 'origin\n', stderr: '' } + } + if (args[0] === 'fetch') { + return { stdout: '', stderr: '' } + } + if ( + args[0] === 'rev-parse' && + args[1] === '--verify' && + args[2] === 'origin/feature/add-feature' + ) { + return { stdout: 'pr-head-sha\n', stderr: '' } + } + throw new Error(`unexpected git call: ${args.join(' ')}`) + }) + gitSpy.mockClear() + try { + const result = await runtime.resolveManagedPrBase({ + repoSelector: 'id:repo-1', + prNumber: 42, + headRefName: 'feature/add-feature', + isCrossRepository: false + }) + + expect(result).toMatchObject({ + baseBranch: 'pr-head-sha', + headSha: 'pr-head-sha', + branchNameOverride: 'feature/add-feature' + }) + expect(gitSpy).toHaveBeenCalledWith(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], { + cwd: TEST_REPO_PATH, + wslDistro: 'Ubuntu' + }) + expect(gitSpy).toHaveBeenCalledWith( + [ + 'fetch', + 'origin', + '+refs/heads/feature/add-feature:refs/remotes/origin/feature/add-feature' + ], + { cwd: TEST_REPO_PATH, wslDistro: 'Ubuntu' } + ) + expect(gitSpy).toHaveBeenCalledWith(['rev-parse', '--verify', 'origin/feature/add-feature'], { + cwd: TEST_REPO_PATH, + wslDistro: 'Ubuntu' + }) + } finally { + gitSpy.mockRestore() + } + }) + it('resolves local GitLab fork MR bases from the target project MR head ref', async () => { const localRepo = { id: TEST_REPO_ID, @@ -15465,6 +16637,77 @@ describe('OrcaRuntimeService', () => { } }) + it('routes runtime GitLab fork MR base git calls through the selected WSL project runtime', async () => { + setPlatform('win32') + const localRepo = { + id: TEST_REPO_ID, + path: TEST_REPO_PATH, + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1, + issueSourcePreference: 'origin' as const + } + const runtimeStore = { + ...store, + getRepos: () => [localRepo], + getRepo: (id: string) => (id === localRepo.id ? localRepo : undefined), + getProjects: () => [ + { + id: 'project-1', + displayName: 'repo', + badgeColor: 'blue', + sourceRepoIds: [TEST_REPO_ID], + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }, + createdAt: 0, + updatedAt: 0 + } + ], + getSettings: () => ({ + ...store.getSettings(), + localWindowsRuntimeDefault: { kind: 'windows-host' } + }) + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => { + if (args[0] === 'fetch') { + return { stdout: '', stderr: '' } + } + if (args[0] === 'rev-parse' && args[1] === '--verify' && args[2] === 'FETCH_HEAD') { + return { stdout: 'fork-mr-sha\n', stderr: '' } + } + throw new Error(`unexpected git call: ${args.join(' ')}`) + }) + gitSpy.mockClear() + getGlabKnownHostsMock.mockResolvedValue(['gitlab.com', 'git.internal']) + try { + const result = await runtime.resolveManagedMrBase({ + repoSelector: 'id:repo-1', + mrIid: 42, + sourceBranch: 'contrib/fix', + isCrossRepository: true + }) + + expect(result).toEqual({ baseBranch: 'fork-mr-sha' }) + expect(getGitLabProjectRefForRemoteMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + 'origin', + ['gitlab.com', 'git.internal'], + null, + { wslDistro: 'Ubuntu' } + ) + expect(gitSpy).toHaveBeenCalledWith(['fetch', 'origin', 'refs/merge-requests/42/head'], { + cwd: TEST_REPO_PATH, + wslDistro: 'Ubuntu' + }) + expect(gitSpy).toHaveBeenCalledWith(['rev-parse', '--verify', 'FETCH_HEAD'], { + cwd: TEST_REPO_PATH, + wslDistro: 'Ubuntu' + }) + } finally { + gitSpy.mockRestore() + } + }) + it('resolves SSH GitLab fork MR bases from the target project MR head ref', async () => { const remoteRepo = { id: TEST_REPO_ID, @@ -15855,6 +17098,137 @@ describe('OrcaRuntimeService', () => { expect(result.worktree.createdAt).toBe(result.worktree.lastActivityAt) }) + it('routes runtime worktree creation through the selected WSL project runtime', async () => { + setPlatform('win32') + const runtimeStore = { + ...store, + getProjects: () => [ + { + id: 'project-1', + displayName: 'repo', + badgeColor: 'blue', + sourceRepoIds: [TEST_REPO_ID], + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }, + createdAt: 0, + updatedAt: 0 + } + ], + getSettings: () => ({ + ...store.getSettings(), + localWindowsRuntimeDefault: { kind: 'windows-host' } + }) + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + const createdWorktree = { + path: '/tmp/workspaces/runtime-wsl', + head: 'def', + branch: 'refs/heads/runtime-wsl', + isBare: false, + isMainWorktree: false + } + computeWorktreePathMock.mockReturnValue(createdWorktree.path) + ensurePathWithinWorkspaceMock.mockReturnValue(createdWorktree.path) + vi.mocked(listWorktrees).mockResolvedValue([createdWorktree]) + const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => { + if (args[0] === 'symbolic-ref') { + return { stdout: 'refs/remotes/origin/main\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('refs/heads/runtime-wsl^{commit}')) { + throw new Error('missing local branch') + } + if (args[0] === 'rev-parse' && args[1] === '--path-format=absolute') { + return { stdout: `${TEST_REPO_PATH}/.git\n`, stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/main^{commit}')) { + return { stdout: 'base-sha\n', stderr: '' } + } + if (args[0] === 'remote' && args.length === 1) { + return { stdout: 'origin\n', stderr: '' } + } + if (args[0] === 'remote' && args[1] === 'get-url') { + return { stdout: 'git@github.com:stablyai/orca.git\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + + try { + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'runtime-wsl', + pushTarget: { + remoteName: 'pr-contributor-orca', + branchName: 'contributor/runtime-wsl', + remoteUrl: 'git@github.com:contributor/orca.git' + } + }) + + expect(result.worktree).toMatchObject({ + path: createdWorktree.path, + branch: 'refs/heads/runtime-wsl' + }) + expect(gitSpy).toHaveBeenCalledWith(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], { + cwd: TEST_REPO_PATH, + wslDistro: 'Ubuntu' + }) + expect(getBranchConflictKind).toHaveBeenCalledWith( + TEST_REPO_PATH, + 'runtime-wsl', + 'origin/main', + { wslDistro: 'Ubuntu' } + ) + expect(getPRForBranchMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + 'runtime-wsl', + null, + null, + null, + { localGitExecOptions: { wslDistro: 'Ubuntu' } } + ) + expect(addWorktree).toHaveBeenCalledWith( + TEST_REPO_PATH, + createdWorktree.path, + 'runtime-wsl', + 'origin/main', + false, + false, + { + remoteTrackingBase: { + base: 'origin/main', + branch: 'main', + ref: 'refs/remotes/origin/main', + remote: 'origin' + }, + suggestLocalBaseRefUpdate: true, + wslDistro: 'Ubuntu' + } + ) + expect(gitSpy).toHaveBeenCalledWith( + ['check-ref-format', '--branch', 'contributor/runtime-wsl'], + { cwd: TEST_REPO_PATH, wslDistro: 'Ubuntu' } + ) + expect(gitSpy).toHaveBeenCalledWith( + [ + 'fetch', + 'pr-contributor-orca', + '+refs/heads/contributor/runtime-wsl:refs/remotes/pr-contributor-orca/contributor/runtime-wsl' + ], + { cwd: TEST_REPO_PATH, wslDistro: 'Ubuntu' } + ) + expect(gitSpy).toHaveBeenCalledWith( + [ + 'branch', + '--set-upstream-to', + 'pr-contributor-orca/contributor/runtime-wsl', + 'runtime-wsl' + ], + { cwd: createdWorktree.path, wslDistro: 'Ubuntu' } + ) + expect(listWorktrees).toHaveBeenCalledWith(TEST_REPO_PATH, { wslDistro: 'Ubuntu' }) + } finally { + gitSpy.mockRestore() + } + }) + it('skips archive hooks for CLI worktree removal by default', async () => { const runtime = new OrcaRuntimeService(store) vi.mocked(getEffectiveHooks).mockReturnValue({ @@ -15874,6 +17248,40 @@ describe('OrcaRuntimeService', () => { ) }) + it('routes runtime worktree removal through the selected WSL project runtime', async () => { + setPlatform('win32') + const runtimeStore = { + ...store, + getProjects: () => [ + { + id: 'project-1', + displayName: 'repo', + badgeColor: 'blue', + sourceRepoIds: [TEST_REPO_ID], + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }, + createdAt: 0, + updatedAt: 0 + } + ], + getSettings: () => ({ + ...store.getSettings(), + localWindowsRuntimeDefault: { kind: 'windows-host' } + }) + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + vi.mocked(getEffectiveHooks).mockReturnValue(null) + vi.mocked(removeWorktree).mockResolvedValue({}) + + await runtime.removeManagedWorktree(TEST_WORKTREE_ID) + + expect(assertWorktreeCleanForRemoval).toHaveBeenCalledWith(TEST_WORKTREE_PATH, false, { + wslDistro: 'Ubuntu' + }) + expect(removeWorktree).toHaveBeenCalledWith(TEST_REPO_PATH, TEST_WORKTREE_PATH, false, { + wslDistro: 'Ubuntu' + }) + }) + it('force-deletes a branch that was preserved by runtime worktree removal', async () => { const runtime = new OrcaRuntimeService(store) vi.mocked(removeWorktree).mockResolvedValue({ @@ -15895,6 +17303,47 @@ describe('OrcaRuntimeService', () => { ) }) + it('routes runtime preserved-branch force-delete through the selected WSL project runtime', async () => { + setPlatform('win32') + const runtimeStore = { + ...store, + getProjects: () => [ + { + id: 'project-1', + displayName: 'repo', + badgeColor: 'blue', + sourceRepoIds: [TEST_REPO_ID], + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }, + createdAt: 0, + updatedAt: 0 + } + ], + getSettings: () => ({ + ...store.getSettings(), + localWindowsRuntimeDefault: { kind: 'windows-host' } + }) + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + vi.mocked(removeWorktree).mockResolvedValue({ + preservedBranch: { branchName: 'feature/test', head: 'def456' } + }) + const gitExec = vi.spyOn(gitRunner, 'gitExecFileAsync').mockResolvedValue({ + stdout: '', + stderr: '' + }) + + await runtime.removeManagedWorktree(TEST_WORKTREE_ID) + await runtime.forceDeletePreservedBranch(TEST_WORKTREE_ID, 'feature/test', 'def456') + + const runGit = forceDeleteLocalBranchMock.mock.calls[0]?.[3] + expect(runGit).toEqual(expect.any(Function)) + await runGit?.(['status'], TEST_REPO_PATH) + expect(gitExec).toHaveBeenCalledWith(['status'], { + cwd: TEST_REPO_PATH, + wslDistro: 'Ubuntu' + }) + }) + it('rejects stale preserved-branch runtime cleanup actions with an old head', async () => { const runtime = new OrcaRuntimeService(store) vi.mocked(removeWorktree).mockResolvedValue({ @@ -16258,7 +17707,9 @@ describe('OrcaRuntimeService', () => { expect(runHook).toHaveBeenCalledWith( 'archive', TEST_WORKTREE_PATH, - expect.objectContaining({ id: TEST_REPO_ID, path: TEST_REPO_PATH }) + expect.objectContaining({ id: TEST_REPO_ID, path: TEST_REPO_PATH }), + undefined, + undefined ) expect(removeWorktree).toHaveBeenCalledWith(TEST_REPO_PATH, TEST_WORKTREE_PATH, false) }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index c1b2635b9fa..bff50acc98b 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -55,6 +55,7 @@ import type { GlobalSettings, PersistedUIState, Project, + ProjectUpdateArgs, ProjectHostSetup, ProjectHostSetupCloneArgs, ProjectHostSetupCreateArgs, @@ -365,6 +366,17 @@ import { createHostedReview as createHostedReviewFromRepo, getHostedReviewCreationEligibility as getHostedReviewCreationEligibilityFromRepo } from '../source-control/hosted-review-creation' +import { + getLocalProjectGitExecOptions, + getLocalProjectWorktreeGitOptions, + resolveLocalProjectRuntimeForRepo +} from '../project-runtime-git-options' +import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' +import { + getLocalWorktreePathAccess, + removeLocalWorktreePath, + toLocalWorktreeRuntimePath +} from '../local-worktree-filesystem' import { connect as connectLinear, disconnect as disconnectLinear, @@ -515,7 +527,7 @@ import { forceDeleteLocalBranch, removeWorktree } from '../git/worktree' -import type { AddWorktreeResult } from '../git/worktree' +import type { AddWorktreeOptions, AddWorktreeResult } from '../git/worktree' import { isENOENT } from '../ipc/filesystem-auth' import { createSetupRunnerScript, @@ -637,6 +649,7 @@ type RuntimeStore = { addRepo: Store['addRepo'] updateRepo: Store['updateRepo'] getProjects?: Store['getProjects'] + updateProject?: Store['updateProject'] getProjectHostSetups?: Store['getProjectHostSetups'] createProjectHostSetup?: Store['createProjectHostSetup'] updateProjectHostSetup?: Store['updateProjectHostSetup'] @@ -927,8 +940,17 @@ type WorktreeStartupFollowup = { prompt: string } -function getAgentLaunchPlatformForRepo(repo: Pick): NodeJS.Platform { +function getAgentLaunchPlatformForRepo( + repo: Pick, + projectRuntime?: ProjectExecutionRuntimeResolution +): NodeJS.Platform { if (!repo.connectionId) { + if (projectRuntime?.status === 'repair-required') { + return projectRuntime.repair.preferredRuntime.kind === 'wsl' ? 'linux' : process.platform + } + if (projectRuntime?.status === 'resolved' && projectRuntime.runtime.kind === 'wsl') { + return 'linux' + } return process.platform } return isWindowsAbsolutePathLike(repo.path) ? 'win32' : 'linux' @@ -1060,9 +1082,17 @@ function omitUndefinedProperties>(value: T): P ) as Partial } -async function isRuntimeWorktreePathMissing(repo: Repo, worktreePath: string): Promise { +async function isRuntimeWorktreePathMissing( + repo: Repo, + worktreePath: string, + localWorktreeGitOptions: { wslDistro?: string } = {} +): Promise { if (!repo.connectionId) { - return isWorktreePathMissing(worktreePath) + const access = getLocalWorktreePathAccess(localWorktreeGitOptions) + return isWorktreePathMissing( + toLocalWorktreeRuntimePath(worktreePath, localWorktreeGitOptions), + access.statPath + ) } const fsProvider = getSshFilesystemProvider(repo.connectionId) @@ -1209,7 +1239,8 @@ async function resolveCreateBranchName( branchNameOverride: string | undefined, sanitizedName: string, settings: { branchPrefix: string; branchPrefixCustom?: string }, - username: string | null + username: string | null, + gitOptions: { wslDistro?: string } = {} ): Promise { if (!branchNameOverride) { return computeBranchName(sanitizedName, settings, username) @@ -1217,7 +1248,10 @@ async function resolveCreateBranchName( if (branchNameOverride.startsWith('-')) { throw new Error('Branch name must not start with "-"') } - await gitExecFileAsync(['check-ref-format', '--branch', branchNameOverride], { cwd: repoPath }) + await gitExecFileAsync(['check-ref-format', '--branch', branchNameOverride], { + cwd: repoPath, + ...gitOptions + }) return branchNameOverride } @@ -1254,14 +1288,16 @@ function addListenerToMap(map: Map>, key: string, listener: T) async function canCheckoutExistingLocalBranch( repoPath: string, branchName: string, - baseBranch: string + baseBranch: string, + gitOptions: { wslDistro?: string } = {} ): Promise { let localHead = '' try { const { stdout } = await gitExecFileAsync( ['rev-parse', '--verify', '--quiet', `refs/heads/${branchName}^{commit}`], { - cwd: repoPath + cwd: repoPath, + ...gitOptions } ) localHead = stdout.trim() @@ -1275,7 +1311,7 @@ async function canCheckoutExistingLocalBranch( try { const { stdout } = await gitExecFileAsync( ['rev-parse', '--verify', '--quiet', `${baseBranch}^{commit}`], - { cwd: repoPath } + { cwd: repoPath, ...gitOptions } ) if (stdout.trim() !== localHead) { return false @@ -1284,10 +1320,26 @@ async function canCheckoutExistingLocalBranch( return false } } - const worktrees = await listWorktrees(repoPath) + const worktrees = await listWorktrees(repoPath, gitOptions) return !worktrees.some((worktree) => normalizeLocalBranchName(worktree.branch) === branchName) } +function hasLocalGitOptions(gitOptions: { wslDistro?: string }): boolean { + return Object.keys(gitOptions).length > 0 +} + +function getLocalGitHubPrForBranch( + repoPath: string, + branchName: string, + gitOptions: { wslDistro?: string } +): ReturnType { + return hasLocalGitOptions(gitOptions) + ? getPRForBranch(repoPath, branchName, null, null, null, { + localGitExecOptions: gitOptions + }) + : getPRForBranch(repoPath, branchName) +} + type SelectedReviewBranchInput = { branchNameOverride?: string linkedPR?: number | null @@ -1379,7 +1431,8 @@ function getSelectedReviewLookupHints(args: SelectedReviewBranchInput): { async function getSelectedHostedReviewForBranch( repo: Pick, branchName: string, - args: SelectedReviewBranchInput + args: SelectedReviewBranchInput, + executionOptions: { localGitExecOptions?: { wslDistro?: string } } = {} ): Promise<{ matchesSelected: boolean; number: number } | null> { const selectedReview = getSelectedReviewBranch(args) if (!selectedReview) { @@ -1389,6 +1442,7 @@ async function getSelectedHostedReviewForBranch( repoPath: repo.path, connectionId: repo.connectionId ?? null, branch: branchName, + ...executionOptions, ...getSelectedReviewLookupHints(args) }) if (!review) { @@ -3671,13 +3725,19 @@ export class OrcaRuntimeService { getRuntimeGitRemoteCommitUrl: RuntimeGitCommands['getRuntimeGitRemoteCommitUrl'] = this.gitCommands.getRuntimeGitRemoteCommitUrl.bind(this.gitCommands) - private async resolveRuntimeGitTarget( - worktreeSelector: string - ): Promise<{ worktree: ResolvedWorktree; repo?: Repo; connectionId?: string }> { + private async resolveRuntimeGitTarget(worktreeSelector: string): Promise<{ + worktree: ResolvedWorktree + repo?: Repo + connectionId?: string + localGitOptions?: { wslDistro?: string } + }> { const store = this.requireStore() const worktree = await this.resolveWorktreeSelector(worktreeSelector) const repo = store.getRepo(worktree.repoId) - return { worktree, repo, connectionId: repo?.connectionId ?? undefined } + const connectionId = repo?.connectionId ?? undefined + const localGitOptions = + repo && !connectionId ? getLocalProjectWorktreeGitOptions(store, repo) : {} + return { worktree, repo, connectionId, localGitOptions } } onMobileSessionTabsChanged( @@ -7197,6 +7257,19 @@ export class OrcaRuntimeService { return this.store?.getProjects?.() ?? [] } + updateProject(projectId: string, updates: ProjectUpdateArgs['updates']): Project { + if (!this.store?.updateProject) { + throw new Error('runtime_unavailable') + } + const project = this.store.updateProject(projectId, updates) + if (!project) { + throw new Error(`Project not found: ${projectId}`) + } + this.invalidateResolvedWorktreeCache() + this.notifyReposChanged() + return project + } + listProjectHostSetups(): ProjectHostSetup[] { return this.store?.getProjectHostSetups?.() ?? [] } @@ -8192,14 +8265,41 @@ export class OrcaRuntimeService { return { repo, repoPath: worktree.path } } + private getHostedReviewExecutionOptions( + repo: Repo + ): { localGitExecOptions: { wslDistro?: string } } | undefined { + const localGitOptions = this.getLocalGitExecutionOptionArgs(repo)[0] ?? {} + return Object.keys(localGitOptions).length > 0 + ? { localGitExecOptions: localGitOptions } + : undefined + } + + private getLocalGitExecutionOptionArgs(repo: Repo): [] | [{ wslDistro?: string }] { + const localGitOptions = getLocalProjectWorktreeGitOptions(this.requireStore(), repo) + return Object.keys(localGitOptions).length > 0 ? [localGitOptions] : [] + } + + private getAgentLaunchPlatformForRepo(repo: Repo): NodeJS.Platform { + const projectRuntime = repo.connectionId + ? undefined + : resolveLocalProjectRuntimeForRepo(this.requireStore(), repo) + return getAgentLaunchPlatformForRepo(repo, projectRuntime) + } + async getRepoSlug(repoSelector: string): Promise<{ owner: string; repo: string } | null> { const repo = await this.resolveRepoSelector(repoSelector) - return getRepoSlug(repo.path, repo.connectionId ?? null) + const options = this.getHostedReviewExecutionOptions(repo) + return options + ? getRepoSlug(repo.path, repo.connectionId ?? null, options) + : getRepoSlug(repo.path, repo.connectionId ?? null) } async getRepoUpstream(repoSelector: string): Promise<{ owner: string; repo: string } | null> { const repo = await this.resolveRepoSelector(repoSelector) - return getRepoUpstream(repo.path, repo.connectionId ?? null) + const options = this.getHostedReviewExecutionOptions(repo) + return options + ? getRepoUpstream(repo.path, repo.connectionId ?? null, options) + : getRepoUpstream(repo.path, repo.connectionId ?? null) } // Why: repos added before fork detection existed have no stored `upstream`, so @@ -8252,7 +8352,8 @@ export class OrcaRuntimeService { before, repo.issueSourcePreference, repo.connectionId ?? null, - noCache + noCache, + ...this.getLocalGitExecutionOptionArgs(repo) ) } @@ -8265,7 +8366,8 @@ export class OrcaRuntimeService { repo.path, limit, repo.issueSourcePreference, - repo.connectionId ?? null + repo.connectionId ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) ) return result.items } @@ -8276,7 +8378,13 @@ export class OrcaRuntimeService { type?: 'issue' | 'pr' ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) - return getWorkItem(repo.path, number, type, repo.connectionId ?? null) + return getWorkItem( + repo.path, + number, + type, + repo.connectionId ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) + ) } async getRepoWorkItemByOwnerRepo( @@ -8286,7 +8394,14 @@ export class OrcaRuntimeService { type: 'issue' | 'pr' ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) - return getWorkItemByOwnerRepo(repo.path, ownerRepo, number, type, repo.connectionId ?? null) + return getWorkItemByOwnerRepo( + repo.path, + ownerRepo, + number, + type, + repo.connectionId ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) + ) } async getRepoWorkItemDetails( @@ -8295,24 +8410,46 @@ export class OrcaRuntimeService { type?: 'issue' | 'pr' ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) - return getWorkItemDetails(repo.path, number, type, repo.connectionId ?? null) + return getWorkItemDetails( + repo.path, + number, + type, + repo.connectionId ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) + ) } async countRepoWorkItems(repoSelector: string, query?: string): Promise { const repo = await this.resolveRepoSelector(repoSelector) - return countWorkItems(repo.path, query, repo.issueSourcePreference, repo.connectionId ?? null) + return countWorkItems( + repo.path, + query, + repo.issueSourcePreference, + repo.connectionId ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) + ) } async listRepoLabels(repoSelector: string): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) - return listLabels(repo.path, repo.issueSourcePreference, repo.connectionId ?? null) + return listLabels( + repo.path, + repo.issueSourcePreference, + repo.connectionId ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) + ) } async listRepoAssignableUsers( repoSelector: string ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) - return listAssignableUsers(repo.path, repo.issueSourcePreference, repo.connectionId ?? null) + return listAssignableUsers( + repo.path, + repo.issueSourcePreference, + repo.connectionId ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) + ) } getGitHubRateLimit(options?: { @@ -8328,12 +8465,14 @@ export class OrcaRuntimeService { fallbackPRNumber?: number | null ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) + const options = this.getHostedReviewExecutionOptions(repo) return getPRForBranch( repo.path, branch, linkedPRNumber ?? null, repo.connectionId ?? null, - linkedPRNumber == null ? (fallbackPRNumber ?? null) : null + linkedPRNumber == null ? (fallbackPRNumber ?? null) : null, + options ) } @@ -8348,6 +8487,7 @@ export class OrcaRuntimeService { linkedGiteaPR?: number | null }): Promise { const repo = await this.resolveRepoSelector(args.repoSelector) + const executionOptions = this.getHostedReviewExecutionOptions(repo) const review = await getHostedReviewForBranchFromRepo({ repoPath: repo.path, connectionId: repo.connectionId ?? null, @@ -8357,7 +8497,8 @@ export class OrcaRuntimeService { linkedGitLabMR: args.linkedGitLabMR ?? null, linkedBitbucketPR: args.linkedBitbucketPR ?? null, linkedAzureDevOpsPR: args.linkedAzureDevOpsPR ?? null, - linkedGiteaPR: args.linkedGiteaPR ?? null + linkedGiteaPR: args.linkedGiteaPR ?? null, + ...executionOptions }) if (review?.provider === 'github' && this.stats && !this.stats.hasCountedPR(review.url)) { this.stats.record({ @@ -8377,6 +8518,7 @@ export class OrcaRuntimeService { } ): Promise { const { repo, repoPath } = await this.resolveHostedReviewTarget(args) + const executionOptions = this.getHostedReviewExecutionOptions(repo) return getHostedReviewCreationEligibilityFromRepo({ repoPath, connectionId: repo.connectionId ?? null, @@ -8391,7 +8533,8 @@ export class OrcaRuntimeService { linkedGitLabMR: args.linkedGitLabMR ?? null, linkedBitbucketPR: args.linkedBitbucketPR ?? null, linkedAzureDevOpsPR: args.linkedAzureDevOpsPR ?? null, - linkedGiteaPR: args.linkedGiteaPR ?? null + linkedGiteaPR: args.linkedGiteaPR ?? null, + ...executionOptions }) } @@ -8399,19 +8542,24 @@ export class OrcaRuntimeService { args: CreateHostedReviewInput & { repoSelector: string; worktreeSelector?: string } ): Promise { const { repo, repoPath } = await this.resolveHostedReviewTarget(args) - const result = await createHostedReviewFromRepo( - repoPath, - { - provider: args.provider, - base: args.base, - head: args.head, - title: args.title, - body: args.body, - draft: args.draft, - useTemplate: args.useTemplate - }, - repo.connectionId ?? null - ) + const executionOptions = this.getHostedReviewExecutionOptions(repo) + const input = { + provider: args.provider, + base: args.base, + head: args.head, + title: args.title, + body: args.body, + draft: args.draft, + ...(args.useTemplate !== undefined ? { useTemplate: args.useTemplate } : {}) + } + const result = executionOptions + ? await createHostedReviewFromRepo( + repoPath, + input, + repo.connectionId ?? null, + executionOptions + ) + : await createHostedReviewFromRepo(repoPath, input, repo.connectionId ?? null) if (result.ok && this.stats && !this.stats.hasCountedPR(result.url)) { this.stats.record({ type: 'pr_created', @@ -8438,7 +8586,8 @@ export class OrcaRuntimeService { perPage ?? 20, repo.issueSourcePreference, query, - repo.connectionId ?? null + repo.connectionId ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) ) } @@ -8457,7 +8606,8 @@ export class OrcaRuntimeService { normalizeGitLabPositiveInteger(perPage, 20, 100), repo.issueSourcePreference, query, - repo.connectionId ?? null + repo.connectionId ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) ) } @@ -8478,7 +8628,8 @@ export class OrcaRuntimeService { repo.issueSourcePreference, normalized.state, normalized.assignee, - repo.connectionId ?? null + repo.connectionId ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) ) // Why: web runtime mirrors the desktop preload contract, where GitLab // issue rows share the GitLabWorkItem shape with MRs on TaskPage. @@ -8501,7 +8652,11 @@ export class OrcaRuntimeService { repoSelector: string ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) - return listGitLabTodos(repo.path, repo.connectionId ?? null) + return listGitLabTodos( + repo.path, + repo.connectionId ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) + ) } async diagnoseGitLabAuth(): Promise>> { @@ -8519,7 +8674,12 @@ export class OrcaRuntimeService { repoSelector: string ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) - return listGitLabLabels(repo.path, repo.issueSourcePreference, repo.connectionId ?? null) + return listGitLabLabels( + repo.path, + repo.issueSourcePreference, + repo.connectionId ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) + ) } async createGitLabRepoIssue( @@ -8533,7 +8693,8 @@ export class OrcaRuntimeService { title, body, repo.issueSourcePreference, - repo.connectionId ?? null + repo.connectionId ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) ) } @@ -8550,7 +8711,8 @@ export class OrcaRuntimeService { updates, repo.issueSourcePreference, repo.connectionId ?? null, - projectRef + projectRef, + ...this.getLocalGitExecutionOptionArgs(repo) ) } @@ -8567,7 +8729,8 @@ export class OrcaRuntimeService { body, repo.issueSourcePreference, repo.connectionId ?? null, - projectRef + projectRef, + ...this.getLocalGitExecutionOptionArgs(repo) ) } @@ -8584,7 +8747,8 @@ export class OrcaRuntimeService { body, repo.issueSourcePreference, repo.connectionId ?? null, - projectRef + projectRef, + ...this.getLocalGitExecutionOptionArgs(repo) ) } @@ -8601,7 +8765,8 @@ export class OrcaRuntimeService { input, repo.issueSourcePreference, repo.connectionId ?? null, - projectRef + projectRef, + ...this.getLocalGitExecutionOptionArgs(repo) ) } @@ -8620,7 +8785,8 @@ export class OrcaRuntimeService { resolved, repo.issueSourcePreference, repo.connectionId ?? null, - projectRef + projectRef, + ...this.getLocalGitExecutionOptionArgs(repo) ) } @@ -8635,7 +8801,8 @@ export class OrcaRuntimeService { jobId, repo.issueSourcePreference, repo.connectionId ?? null, - projectRef + projectRef, + ...this.getLocalGitExecutionOptionArgs(repo) ) } @@ -8650,7 +8817,8 @@ export class OrcaRuntimeService { jobId, repo.issueSourcePreference, repo.connectionId ?? null, - projectRef + projectRef, + ...this.getLocalGitExecutionOptionArgs(repo) ) } @@ -8667,7 +8835,8 @@ export class OrcaRuntimeService { method ?? 'merge', repo.issueSourcePreference, repo.connectionId ?? null, - projectRef + projectRef, + ...this.getLocalGitExecutionOptionArgs(repo) ) } @@ -8684,14 +8853,16 @@ export class OrcaRuntimeService { iid, repo.issueSourcePreference, repo.connectionId ?? null, - projectRef + projectRef, + ...this.getLocalGitExecutionOptionArgs(repo) ) : reopenGitLabMR( repo.path, iid, repo.issueSourcePreference, repo.connectionId ?? null, - projectRef + projectRef, + ...this.getLocalGitExecutionOptionArgs(repo) ) } @@ -8708,7 +8879,8 @@ export class OrcaRuntimeService { updates, repo.issueSourcePreference, repo.connectionId ?? null, - projectRef + projectRef, + ...this.getLocalGitExecutionOptionArgs(repo) ) } @@ -8725,7 +8897,8 @@ export class OrcaRuntimeService { reviewerIds, repo.issueSourcePreference, repo.connectionId ?? null, - projectRef + projectRef, + ...this.getLocalGitExecutionOptionArgs(repo) ) } @@ -8742,7 +8915,8 @@ export class OrcaRuntimeService { type, repo.issueSourcePreference, repo.connectionId ?? null, - projectRef + projectRef, + ...this.getLocalGitExecutionOptionArgs(repo) ) } @@ -8758,7 +8932,8 @@ export class OrcaRuntimeService { projectRef, iid, type, - repo.connectionId ?? null + repo.connectionId ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) ) // Why: remote pasted-URL lookups should update GitLab recents exactly // like the desktop IPC path, but only after a successful lookup. @@ -8781,7 +8956,12 @@ export class OrcaRuntimeService { number: number ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) - return getIssue(repo.path, number, repo.connectionId ?? null) + return getIssue( + repo.path, + number, + repo.connectionId ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) + ) } async getRepoPRChecks( @@ -8798,7 +8978,8 @@ export class OrcaRuntimeService { headSha, prRepo ?? null, options, - repo.connectionId ?? null + repo.connectionId ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) ) } @@ -8808,7 +8989,13 @@ export class OrcaRuntimeService { options?: { headSha?: string; failedOnly?: boolean } ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) - return rerunPRChecks(repo.path, prNumber, options, repo.connectionId ?? null) + return rerunPRChecks( + repo.path, + prNumber, + options, + repo.connectionId ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) + ) } async getRepoPRCheckDetails( @@ -8825,7 +9012,8 @@ export class OrcaRuntimeService { return getPRCheckDetails( repo.path, { ...args, prRepo: args.prRepo ?? null }, - repo.connectionId ?? null + repo.connectionId ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) ) } @@ -8840,7 +9028,8 @@ export class OrcaRuntimeService { repo.path, prNumber, { ...options, prRepo: prRepo ?? null }, - repo.connectionId ?? null + repo.connectionId ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) ) } @@ -8859,6 +9048,7 @@ export class OrcaRuntimeService { return getPRFileContents({ repoPath: repo.path, connectionId: repo.connectionId ?? null, + localGitOptions: this.getLocalGitExecutionOptionArgs(repo)[0], ...args }) } @@ -8869,7 +9059,13 @@ export class OrcaRuntimeService { resolve: boolean ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) - return resolveReviewThread(repo.path, threadId, resolve, repo.connectionId ?? null) + return resolveReviewThread( + repo.path, + threadId, + resolve, + repo.connectionId ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) + ) } async setRepoPRFileViewed( @@ -8884,6 +9080,7 @@ export class OrcaRuntimeService { return setPRFileViewed({ repoPath: repo.path, connectionId: repo.connectionId ?? null, + localGitOptions: this.getLocalGitExecutionOptionArgs(repo)[0], ...args }) } @@ -8895,7 +9092,14 @@ export class OrcaRuntimeService { prRepo?: GitHubOwnerRepo | null ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) - return updatePRTitle(repo.path, prNumber, title, repo.connectionId ?? null, prRepo ?? null) + return updatePRTitle( + repo.path, + prNumber, + title, + repo.connectionId ?? null, + prRepo ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) + ) } async updateRepoPRDetails( @@ -8905,7 +9109,14 @@ export class OrcaRuntimeService { prRepo?: GitHubOwnerRepo | null ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) - return updatePRDetails(repo.path, prNumber, updates, repo.connectionId ?? null, prRepo ?? null) + return updatePRDetails( + repo.path, + prNumber, + updates, + repo.connectionId ?? null, + prRepo ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) + ) } async mergeRepoPR( @@ -8915,7 +9126,14 @@ export class OrcaRuntimeService { prRepo?: GitHubOwnerRepo | null ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) - return mergePR(repo.path, prNumber, method, repo.connectionId ?? null, prRepo ?? null) + return mergePR( + repo.path, + prNumber, + method, + repo.connectionId ?? null, + prRepo ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) + ) } async setRepoPRAutoMerge( @@ -8932,7 +9150,8 @@ export class OrcaRuntimeService { enabled, method, repo.connectionId ?? null, - prRepo ?? null + prRepo ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) ) } @@ -8942,7 +9161,13 @@ export class OrcaRuntimeService { updates: GitHubPullRequestStateUpdate ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) - return updatePRState(repo.path, prNumber, updates, repo.connectionId ?? null) + return updatePRState( + repo.path, + prNumber, + updates, + repo.connectionId ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) + ) } async requestRepoPRReviewers( @@ -8951,7 +9176,13 @@ export class OrcaRuntimeService { reviewers: string[] ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) - return requestPRReviewers(repo.path, prNumber, reviewers, repo.connectionId ?? null) + return requestPRReviewers( + repo.path, + prNumber, + reviewers, + repo.connectionId ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) + ) } async removeRepoPRReviewers( @@ -8961,7 +9192,13 @@ export class OrcaRuntimeService { ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) this.assertHostIntegrationRepoIsLocal(repo, 'repo_pr_reviewers') - return removePRReviewers(repo.path, prNumber, reviewers) + return removePRReviewers( + repo.path, + prNumber, + reviewers, + repo.connectionId ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) + ) } async createRepoIssue( @@ -8977,7 +9214,8 @@ export class OrcaRuntimeService { body, repo.issueSourcePreference, repo.connectionId ?? null, - fields + fields, + ...this.getLocalGitExecutionOptionArgs(repo) ) } @@ -8987,7 +9225,13 @@ export class OrcaRuntimeService { updates: GitHubIssueUpdate ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) - return updateIssue(repo.path, number, updates, repo.connectionId ?? null) + return updateIssue( + repo.path, + number, + updates, + repo.connectionId ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) + ) } async addRepoIssueComment( @@ -8997,7 +9241,14 @@ export class OrcaRuntimeService { prRepo?: GitHubOwnerRepo | null ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) - return addIssueComment(repo.path, number, body, repo.connectionId ?? null, prRepo ?? null) + return addIssueComment( + repo.path, + number, + body, + repo.connectionId ?? null, + prRepo ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) + ) } async addRepoPRReviewComment( @@ -9008,6 +9259,7 @@ export class OrcaRuntimeService { return addPRReviewComment({ repoPath: repo.path, connectionId: repo.connectionId ?? null, + localGitOptions: this.getLocalGitExecutionOptionArgs(repo)[0], ...args }) } @@ -9034,7 +9286,8 @@ export class OrcaRuntimeService { args.path, args.line, repo.connectionId ?? null, - args.prRepo ?? null + args.prRepo ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) ) } @@ -9590,7 +9843,7 @@ export class OrcaRuntimeService { // Why: a mobile client can run on Windows while the workspace shell is // Linux over SSH. Startup command quoting must target the shell that runs it. - const agentLaunchPlatform = getAgentLaunchPlatformForRepo(repo) + const agentLaunchPlatform = this.getAgentLaunchPlatformForRepo(repo) const draftLaunchPlan = buildAgentDraftLaunchPlan({ agent, draft: content, @@ -9645,7 +9898,7 @@ export class OrcaRuntimeService { } // Why: CLI clients may target SSH runtimes from macOS/Windows, so quote for // the workspace shell rather than the client shell. - const agentLaunchPlatform = getAgentLaunchPlatformForRepo(repo) + const agentLaunchPlatform = this.getAgentLaunchPlatformForRepo(repo) const startupPlan = buildAgentStartupPlan({ agent, prompt: prompt ?? '', @@ -10163,6 +10416,19 @@ export class OrcaRuntimeService { } const settings = createSettings const worktreePathSettings = getWorktreePathSettings(repo, settings) + const localGitExecOptions = getLocalProjectGitExecOptions(this.requireStore(), repo) + const localWorktreeGitOptions = getLocalProjectWorktreeGitOptions(this.requireStore(), repo) + const hasLocalWorktreeGitOptions = hasLocalGitOptions(localWorktreeGitOptions) + const localWorktreeGitOptionArgs: [] | [{ wslDistro?: string }] = hasLocalWorktreeGitOptions + ? [localWorktreeGitOptions] + : [] + const addProjectGitOptions = (options?: AddWorktreeOptions): AddWorktreeOptions | undefined => { + if (!hasLocalWorktreeGitOptions) { + return options + } + return { ...options, ...localWorktreeGitOptions } + } + const hostedReviewExecutionContext = this.getHostedReviewExecutionOptions(repo) let effectiveRequestedName = args.name const requestedDisplayName = args.displayName?.trim() || undefined const sanitizedName = sanitizeWorktreeName(args.name) @@ -10173,10 +10439,16 @@ export class OrcaRuntimeService { args.branchNameOverride, sanitizedName, settings, - username + username, + localWorktreeGitOptions ) - const baseBranch = args.baseBranch || repo.worktreeBaseRef || getDefaultBaseRef(repo.path) + const baseBranch = + args.baseBranch || + repo.worktreeBaseRef || + (hasLocalWorktreeGitOptions + ? await resolveDefaultBaseRefViaExec((argv) => gitExecFileAsync(argv, localGitExecOptions)) + : getDefaultBaseRef(repo.path)) if (!baseBranch) { // Why: getDefaultBaseRef returns null when no suitable ref exists. // Don't fabricate 'origin/main' — passing it to addWorktree would @@ -10190,11 +10462,17 @@ export class OrcaRuntimeService { const checkoutExistingBranch = await canCheckoutExistingLocalBranch( repo.path, branchName, - baseBranch + baseBranch, + ...localWorktreeGitOptionArgs ) let branchConflictKind = checkoutExistingBranch ? null - : await getBranchConflictKind(repo.path, branchName, baseBranch) + : await getBranchConflictKind( + repo.path, + branchName, + baseBranch, + ...localWorktreeGitOptionArgs + ) const allowedPushTargetRemoteConflict = branchConflictKind && isAllowedPushTargetRemoteConflict(branchConflictKind, branchName, args) if (branchConflictKind && !allowedPushTargetRemoteConflict) { @@ -10208,7 +10486,11 @@ export class OrcaRuntimeService { const selectedReview = getSelectedReviewBranch(args) if (selectedReview?.provider === 'github' || !allowedPushTargetRemoteConflict) { try { - existingPR = await getPRForBranch(repo.path, branchName) + existingPR = await getLocalGitHubPrForBranch( + repo.path, + branchName, + localWorktreeGitOptions + ) } catch { if (allowedPushTargetRemoteConflict) { throw new Error(`Could not verify selected PR branch "${branchName}". Try again.`) @@ -10227,9 +10509,12 @@ export class OrcaRuntimeService { throw new Error(`Branch "${branchName}" already exists on a remote.`) } } else if (selectedReview) { - const hostedReview = await getSelectedHostedReviewForBranch(repo, branchName, args).catch( - () => null - ) + const hostedReview = await getSelectedHostedReviewForBranch( + repo, + branchName, + args, + hostedReviewExecutionContext + ).catch(() => null) if (!hostedReview?.matchesSelected) { if (hostedReview) { throw new Error(`Branch "${branchName}" already has PR #${hostedReview.number}.`) @@ -10271,19 +10556,35 @@ export class OrcaRuntimeService { `Could not find an available worktree path for "${sanitizedName}". Pick a different worktree name.` ) } - const remoteTrackingBase = await this.resolveRemoteTrackingBase(repo.path, baseBranch) + const remoteTrackingBase = await this.resolveRemoteTrackingBase( + repo.path, + baseBranch, + ...localWorktreeGitOptionArgs + ) if (remoteTrackingBase) { - const hadLocalBaseRef = await this.hasRemoteTrackingRef(repo.path, remoteTrackingBase) + const hadLocalBaseRef = await this.hasRemoteTrackingRef( + repo.path, + remoteTrackingBase, + ...localWorktreeGitOptionArgs + ) const refreshResult = await this.getOrStartRemoteTrackingBaseRefresh( repo.path, - remoteTrackingBase + remoteTrackingBase, + ...localWorktreeGitOptionArgs ) if (!refreshResult.ok) { throw new Error( `Could not refresh base ref "${baseBranch}" from "${remoteTrackingBase.remote}". Check your network and try again.` ) } - if (!hadLocalBaseRef && !(await this.hasRemoteTrackingRef(repo.path, remoteTrackingBase))) { + if ( + !hadLocalBaseRef && + !(await this.hasRemoteTrackingRef( + repo.path, + remoteTrackingBase, + ...localWorktreeGitOptionArgs + )) + ) { throw new Error(`Base ref "${baseBranch}" was not found after fetching.`) } } else if (!(await hasLocalCommitObject(repo.path, baseBranch))) { @@ -10291,7 +10592,7 @@ export class OrcaRuntimeService { // Why: local bases keep legacy best-effort fetch behavior. Verified PR // SHA bases already have the commit object needed by `git worktree add`. try { - await this.fetchRemoteWithCache(repo.path, remote) + await this.fetchRemoteWithCache(repo.path, remote, ...localWorktreeGitOptionArgs) } catch { // Why: belt-and-suspenders. fetchRemoteWithCache already logs and does // not throw; the outer try/catch guarantees create-path tolerance even @@ -10315,7 +10616,8 @@ export class OrcaRuntimeService { repo.path, args.pushTarget, this.store, - repo.id + repo.id, + localWorktreeGitOptions ) } @@ -10329,6 +10631,7 @@ export class OrcaRuntimeService { ...remoteTrackingBaseOption, ...(suggestLocalBaseRefUpdate ? { suggestLocalBaseRefUpdate } : {}) } + const defaultAddWorktreeOption = addProjectGitOptions() const addResult: AddWorktreeResult = (await (sparseDirectories.length > 0 ? checkoutExistingBranch @@ -10339,7 +10642,7 @@ export class OrcaRuntimeService { sparseDirectories, baseBranch, settings.refreshLocalBaseRefOnWorktreeCreate, - existingBranchOption + addProjectGitOptions(existingBranchOption) ) : suggestLocalBaseRefUpdate ? addSparseWorktree( @@ -10349,7 +10652,7 @@ export class OrcaRuntimeService { sparseDirectories, baseBranch, settings.refreshLocalBaseRefOnWorktreeCreate, - { ...remoteTrackingBaseOption, suggestLocalBaseRefUpdate } + addProjectGitOptions({ ...remoteTrackingBaseOption, suggestLocalBaseRefUpdate }) ) : remoteTrackingBaseOption ? addSparseWorktree( @@ -10359,16 +10662,26 @@ export class OrcaRuntimeService { sparseDirectories, baseBranch, settings.refreshLocalBaseRefOnWorktreeCreate, - remoteTrackingBaseOption - ) - : addSparseWorktree( - repo.path, - worktreePath, - branchName, - sparseDirectories, - baseBranch, - settings.refreshLocalBaseRefOnWorktreeCreate + addProjectGitOptions(remoteTrackingBaseOption) ) + : defaultAddWorktreeOption + ? addSparseWorktree( + repo.path, + worktreePath, + branchName, + sparseDirectories, + baseBranch, + settings.refreshLocalBaseRefOnWorktreeCreate, + defaultAddWorktreeOption + ) + : addSparseWorktree( + repo.path, + worktreePath, + branchName, + sparseDirectories, + baseBranch, + settings.refreshLocalBaseRefOnWorktreeCreate + ) : checkoutExistingBranch ? addWorktree( repo.path, @@ -10377,7 +10690,7 @@ export class OrcaRuntimeService { baseBranch, settings.refreshLocalBaseRefOnWorktreeCreate, false, - existingBranchOption + addProjectGitOptions(existingBranchOption) ) : suggestLocalBaseRefUpdate ? addWorktree( @@ -10387,7 +10700,7 @@ export class OrcaRuntimeService { baseBranch, settings.refreshLocalBaseRefOnWorktreeCreate, false, - { ...remoteTrackingBaseOption, suggestLocalBaseRefUpdate } + addProjectGitOptions({ ...remoteTrackingBaseOption, suggestLocalBaseRefUpdate }) ) : remoteTrackingBaseOption ? addWorktree( @@ -10397,26 +10710,39 @@ export class OrcaRuntimeService { baseBranch, settings.refreshLocalBaseRefOnWorktreeCreate, false, - remoteTrackingBaseOption + addProjectGitOptions(remoteTrackingBaseOption) ) - : addWorktree( - repo.path, - worktreePath, - branchName, - baseBranch, - settings.refreshLocalBaseRefOnWorktreeCreate - ))) ?? {} + : defaultAddWorktreeOption + ? addWorktree( + repo.path, + worktreePath, + branchName, + baseBranch, + settings.refreshLocalBaseRefOnWorktreeCreate, + false, + defaultAddWorktreeOption + ) + : addWorktree( + repo.path, + worktreePath, + branchName, + baseBranch, + settings.refreshLocalBaseRefOnWorktreeCreate + ))) ?? {} let configuredPushTarget: GitPushTarget | undefined if (preparedPushTarget) { configuredPushTarget = await configureCreatedWorktreePushTarget( worktreePath, branchName, - preparedPushTarget + preparedPushTarget, + localWorktreeGitOptions ) } - const gitWorktrees = await listWorktrees(repo.path) + const gitWorktrees = hasLocalWorktreeGitOptions + ? await listWorktrees(repo.path, localWorktreeGitOptions) + : await listWorktrees(repo.path) const created = gitWorktrees.find((gw) => areWorktreePathsEqual(gw.path, worktreePath)) if (!created) { throw new Error('Worktree created but not found in listing') @@ -10532,7 +10858,12 @@ export class OrcaRuntimeService { // renderer create flow so repo-committed `orca.yaml` setup hooks run in // the visible first terminal instead of a hidden background shell with // different failure and prompt behavior. - setup = createSetupRunnerScript(repo, worktreePath, hooks.scripts.setup) + setup = createSetupRunnerScript( + repo, + worktreePath, + hooks.scripts.setup, + this.getLocalGitExecutionOptionArgs(repo)[0] + ) } catch (error) { // Why: the git worktree is already real at this point. If runner // generation fails, keep creation successful and surface the problem in @@ -10540,7 +10871,13 @@ export class OrcaRuntimeService { console.error(`[hooks] Failed to prepare setup runner for ${worktreePath}:`, error) } } else { - void runHook('setup', worktreePath, repo, worktreePath).then((result) => { + void runHook( + 'setup', + worktreePath, + repo, + worktreePath, + this.getLocalGitExecutionOptionArgs(repo)[0] + ).then((result) => { if (!result.success) { console.error(`[hooks] setup hook failed for ${worktreePath}:`, result.output) } @@ -10973,8 +11310,13 @@ export class OrcaRuntimeService { * primary telemetry target; splitting the cache by call-site would double * the fetch load on warm repos. */ - async getCanonicalFetchKey(repoPath: string, remote: string): Promise { - const cacheKey = `${repoPath}::${remote}` + async getCanonicalFetchKey( + repoPath: string, + remote: string, + gitOptions: { wslDistro?: string } = {} + ): Promise { + const runtimeKey = gitOptions.wslDistro ? `wsl:${gitOptions.wslDistro}` : 'local' + const cacheKey = `${runtimeKey}::${repoPath}::${remote}` const cached = this.canonicalFetchKeyCache.get(cacheKey) if (cached !== undefined) { setBoundedMapEntry(this.canonicalFetchKeyCache, cacheKey, cached, REMOTE_FETCH_CACHE_MAX) @@ -10984,11 +11326,11 @@ export class OrcaRuntimeService { try { const { stdout } = await gitExecFileAsync( ['rev-parse', '--path-format=absolute', '--git-common-dir'], - { cwd: repoPath } + { cwd: repoPath, ...gitOptions } ) const commonDir = stdout.trim() if (commonDir) { - resolved = `${commonDir}::${remote}` + resolved = `${runtimeKey}::${commonDir}::${remote}` } } catch { // Fall through to the caller-provided path. The fetch still runs from @@ -11030,8 +11372,12 @@ export class OrcaRuntimeService { setBoundedMapEntry(this.fetchLastCompletedAt, key, completedAt, REMOTE_FETCH_CACHE_MAX) } - async getOrStartRemoteFetch(repoPath: string, remote: string): Promise { - const key = await this.getCanonicalFetchKey(repoPath, remote) + async getOrStartRemoteFetch( + repoPath: string, + remote: string, + gitOptions: { wslDistro?: string } = {} + ): Promise { + const key = await this.getCanonicalFetchKey(repoPath, remote, gitOptions) if (this.getFreshFetchCompletedAt(key) !== null) { // Why: freshness window hit — skip the fetch entirely. Do NOT reuse any // in-flight promise here; the timestamp is only written on success, so @@ -11047,7 +11393,7 @@ export class OrcaRuntimeService { } const promise = this.enqueueRemoteFetch(key, () => - gitExecFileAsync(['fetch', remote], { cwd: repoPath }) + gitExecFileAsync(['fetch', remote], { cwd: repoPath, ...gitOptions }) .then((): RemoteFetchResult => { // Why (§3.3 Lifecycle): timestamp on success ONLY. Writing on rejection // would make the freshness cache lie about the last known remote state. @@ -11074,10 +11420,15 @@ export class OrcaRuntimeService { async getOrStartRemoteTrackingBaseRefresh( repoPath: string, - base: RemoteTrackingBase + base: RemoteTrackingBase, + gitOptions: { wslDistro?: string } = {} ): Promise { - const remoteKey = await this.getCanonicalFetchKey(repoPath, base.remote) - const key = await this.getCanonicalFetchKey(repoPath, `base:${base.remote}:${base.branch}`) + const remoteKey = await this.getCanonicalFetchKey(repoPath, base.remote, gitOptions) + const key = await this.getCanonicalFetchKey( + repoPath, + `base:${base.remote}:${base.branch}`, + gitOptions + ) if (this.getFreshFetchCompletedAt(key) !== null) { // Why: exact-base freshness is the safety boundary. A full remote fetch // can be narrowed by repo refspecs, so it must not prove this branch. @@ -11095,7 +11446,7 @@ export class OrcaRuntimeService { } return gitExecFileAsync( ['fetch', '--no-tags', base.remote, `+refs/heads/${base.branch}:${base.ref}`], - { cwd: repoPath } + { cwd: repoPath, ...gitOptions } ) .then((): RemoteFetchResult => { this.rememberFreshFetchCompletedAt(key) @@ -11116,17 +11467,22 @@ export class OrcaRuntimeService { return promise } - async fetchRemoteWithCache(repoPath: string, remote: string): Promise { - await this.getOrStartRemoteFetch(repoPath, remote) + async fetchRemoteWithCache( + repoPath: string, + remote: string, + gitOptions: { wslDistro?: string } = {} + ): Promise { + await this.getOrStartRemoteFetch(repoPath, remote, gitOptions) } async resolveRemoteTrackingBase( repoPath: string, - baseBranch: string + baseBranch: string, + gitOptions: { wslDistro?: string } = {} ): Promise { let remotes: string[] try { - const { stdout } = await gitExecFileAsync(['remote'], { cwd: repoPath }) + const { stdout } = await gitExecFileAsync(['remote'], { cwd: repoPath, ...gitOptions }) remotes = stdout .split('\n') .map((line) => line.trim()) @@ -11157,9 +11513,16 @@ export class OrcaRuntimeService { } } - async hasRemoteTrackingRef(repoPath: string, base: RemoteTrackingBase): Promise { + async hasRemoteTrackingRef( + repoPath: string, + base: RemoteTrackingBase, + gitOptions: { wslDistro?: string } = {} + ): Promise { try { - await gitExecFileAsync(['rev-parse', '--verify', `${base.ref}^{commit}`], { cwd: repoPath }) + await gitExecFileAsync(['rev-parse', '--verify', `${base.ref}^{commit}`], { + cwd: repoPath, + ...gitOptions + }) return true } catch { return false @@ -11350,28 +11713,43 @@ export class OrcaRuntimeService { // instead of probing a server path on the desktop filesystem. return null } + const localGitExecOptions = getLocalProjectGitExecOptions(this.requireStore(), repo) + const localWorktreeGitOptions = getLocalProjectWorktreeGitOptions(this.requireStore(), repo) const meta = this.store.getWorktreeMeta(wt.id) const base = - meta?.baseRef || meta?.sparseBaseRef || repo.worktreeBaseRef || getDefaultBaseRef(repo.path) + meta?.baseRef || + meta?.sparseBaseRef || + repo.worktreeBaseRef || + (await getBaseRefDefault(repo.path, localWorktreeGitOptions)) if (!base) { // Why: brand-new repo with no remote primary — nothing to compare // against, so there's no meaningful drift to report. Dispatch should // not block on a probe that cannot form an opinion. return null } - const remoteTrackingBase = await this.resolveRemoteTrackingBase(repo.path, base) + const remoteTrackingBase = await this.resolveRemoteTrackingBase( + repo.path, + base, + localWorktreeGitOptions + ) if (!remoteTrackingBase) { return null } const remote = remoteTrackingBase.remote // Why: fetch failures are non-fatal; we proceed with whatever the // last-known remote ref points at. `fetchRemoteWithCache` never throws. - await this.fetchRemoteWithCache(repo.path, remote) - const drift = getRemoteDrift(wt.path, 'HEAD', base) + await this.fetchRemoteWithCache(repo.path, remote, localWorktreeGitOptions) + const drift = getRemoteDrift(wt.path, 'HEAD', base, localGitExecOptions) if (!drift) { return null } - const recentSubjects = getRecentDriftSubjects(wt.path, 'HEAD', base, DRIFT_PROBE_SUBJECT_LIMIT) + const recentSubjects = getRecentDriftSubjects( + wt.path, + 'HEAD', + base, + DRIFT_PROBE_SUBJECT_LIMIT, + localGitExecOptions + ) return { base, behind: drift.behind, recentSubjects } } @@ -11483,9 +11861,15 @@ export class OrcaRuntimeService { return { error: 'Folder mode does not support creating worktrees.' } } const sshGitProvider = repo.connectionId ? requireSshGitProvider(repo.connectionId) : null + const localGitExecOptions = sshGitProvider + ? undefined + : getLocalProjectGitExecOptions(this.requireStore(), repo) + const localWorktreeGitOptions = sshGitProvider + ? {} + : getLocalProjectWorktreeGitOptions(this.requireStore(), repo) const gitExec = sshGitProvider ? (gitArgs: string[]) => sshGitProvider.exec(gitArgs, repo.path) - : (gitArgs: string[]) => gitExecFileAsync(gitArgs, { cwd: repo.path }) + : (gitArgs: string[]) => gitExecFileAsync(gitArgs, localGitExecOptions ?? { cwd: repo.path }) const resolveRemote = sshGitProvider ? async () => { const { stdout } = await sshGitProvider.exec(['remote'], repo.path) @@ -11506,12 +11890,18 @@ export class OrcaRuntimeService { `Repo has multiple remotes (${remotes.join(', ')}) and no default is configured.` ) } - : () => getDefaultRemote(repo.path) + : () => getDefaultRemote(repo.path, localWorktreeGitOptions) // Why: SSH repos can't fetch over the relay's read-only git.exec channel, so // route the PR head fetch through the write-capable helper instead of gitExec. const fetchRemoteTrackingRef = (remote: string, branch: string): Promise => - fetchPrHeadTrackingRef(repo, sshGitProvider, remote, branch) + fetchPrHeadTrackingRef( + repo, + sshGitProvider, + remote, + branch, + localGitExecOptions ? { localGitExecOptions } : {} + ) return resolveGitHubPrStartPoint({ repoPath: repo.path, @@ -11520,6 +11910,7 @@ export class OrcaRuntimeService { baseRefName: args.baseRefName, isCrossRepository: args.isCrossRepository, connectionId: repo.connectionId ?? null, + localGitOptions: localWorktreeGitOptions, gitExec, fetchRemoteTrackingRef, resolveRemote @@ -11548,9 +11939,15 @@ export class OrcaRuntimeService { return { error: 'Folder mode does not support creating worktrees.' } } const sshGitProvider = repo.connectionId ? requireSshGitProvider(repo.connectionId) : null + const localGitExecOptions = sshGitProvider + ? undefined + : getLocalProjectGitExecOptions(this.requireStore(), repo) + const localWorktreeGitOptions = sshGitProvider + ? {} + : getLocalProjectWorktreeGitOptions(this.requireStore(), repo) const gitExec = sshGitProvider ? (gitArgs: string[]) => sshGitProvider.exec(gitArgs, repo.path) - : (gitArgs: string[]) => gitExecFileAsync(gitArgs, { cwd: repo.path }) + : (gitArgs: string[]) => gitExecFileAsync(gitArgs, localGitExecOptions ?? { cwd: repo.path }) let sourceBranch = args.sourceBranch?.trim() ?? '' let targetBranch = args.targetBranch?.trim() ?? '' @@ -11562,7 +11959,8 @@ export class OrcaRuntimeService { remote = await this.resolveGitLabIssueSourceRemote( repo.path, repo.issueSourcePreference, - repo.connectionId ?? null + repo.connectionId ?? null, + localWorktreeGitOptions ) } catch (error) { return { error: error instanceof Error ? error.message : 'Could not resolve git remote.' } @@ -11572,7 +11970,8 @@ export class OrcaRuntimeService { repo.path, remote, knownHosts, - repo.connectionId ?? null + repo.connectionId ?? null, + localWorktreeGitOptions ) if (!projectRef) { return { error: 'No GitLab project found for this repository.' } @@ -11582,7 +11981,8 @@ export class OrcaRuntimeService { projectRef, args.mrIid, 'mr', - repo.connectionId ?? null + repo.connectionId ?? null, + localWorktreeGitOptions ) if (!item || item.type !== 'mr') { return { error: `MR !${args.mrIid} not found.` } @@ -11602,7 +12002,8 @@ export class OrcaRuntimeService { remote = await this.resolveGitLabIssueSourceRemote( repo.path, repo.issueSourcePreference, - repo.connectionId ?? null + repo.connectionId ?? null, + localWorktreeGitOptions ) } catch (error) { return { error: error instanceof Error ? error.message : 'Could not resolve git remote.' } @@ -11682,15 +12083,19 @@ export class OrcaRuntimeService { private async resolveGitLabIssueSourceRemote( repoPath: string, preference?: Repo['issueSourcePreference'], - connectionId?: string | null + connectionId?: string | null, + localGitOptions: { wslDistro?: string } = {} ): Promise { const knownHosts = await getGlabKnownHosts() + const localGitOptionArgs = + Object.keys(localGitOptions).length > 0 ? ([localGitOptions] as const) : [] if (preference === 'origin') { const origin = await getGitLabProjectRefForRemote( repoPath, 'origin', knownHosts, - connectionId + connectionId, + ...localGitOptionArgs ) if (origin) { return 'origin' @@ -11702,7 +12107,8 @@ export class OrcaRuntimeService { repoPath, 'upstream', knownHosts, - connectionId + connectionId, + ...localGitOptionArgs ) if (upstream) { return 'upstream' @@ -11711,7 +12117,8 @@ export class OrcaRuntimeService { repoPath, 'origin', knownHosts, - connectionId + connectionId, + ...localGitOptionArgs ) if (origin) { return 'origin' @@ -11722,12 +12129,19 @@ export class OrcaRuntimeService { repoPath, 'upstream', knownHosts, - connectionId + connectionId, + ...localGitOptionArgs ) if (upstream) { return 'upstream' } - const origin = await getGitLabProjectRefForRemote(repoPath, 'origin', knownHosts, connectionId) + const origin = await getGitLabProjectRefForRemote( + repoPath, + 'origin', + knownHosts, + connectionId, + ...localGitOptionArgs + ) if (origin) { return 'origin' } @@ -11751,7 +12165,7 @@ export class OrcaRuntimeService { `Repo has multiple remotes (${remotes.join(', ')}) and no default is configured.` ) } - return getDefaultRemote(repoPath) + return getDefaultRemote(repoPath, localGitOptions) } private async resolveWorktreeRemovalTarget( @@ -11876,12 +12290,21 @@ export class OrcaRuntimeService { this.store ) } else { - await forceDeleteLocalBranch(repo.path, cleanupTarget.branchName, cleanupTarget.head) + const localWorktreeGitOptions = getLocalProjectWorktreeGitOptions(this.requireStore(), repo) + await (Object.keys(localWorktreeGitOptions).length > 0 + ? forceDeleteLocalBranch( + repo.path, + cleanupTarget.branchName, + cleanupTarget.head, + (argv, cwd) => gitExecFileAsync(argv, { cwd, ...localWorktreeGitOptions }) + ) + : forceDeleteLocalBranch(repo.path, cleanupTarget.branchName, cleanupTarget.head)) await cleanupUnusedWorktreePushTargetRemote( repo.path, removalTarget.id, cleanupTarget.pushTarget, - this.store + this.store, + localWorktreeGitOptions ) } @@ -11941,9 +12364,15 @@ export class OrcaRuntimeService { } const provider = repo.connectionId ? requireSshGitProvider(repo.connectionId) : null const fsProvider = repo.connectionId ? getSshFilesystemProvider(repo.connectionId) : null + const localWorktreeGitOptions = repo.connectionId + ? {} + : getLocalProjectWorktreeGitOptions(this.requireStore(), repo) + const hasLocalWorktreeGitOptions = Object.keys(localWorktreeGitOptions).length > 0 const registeredWorktrees = repo.connectionId ? await provider!.listWorktrees(repo.path) - : await listWorktrees(repo.path) + : hasLocalWorktreeGitOptions + ? await listWorktrees(repo.path, localWorktreeGitOptions) + : await listWorktrees(repo.path) const removedMeta = store.getWorktreeMeta(removalTarget.id) const removedPushTarget = removedMeta?.pushTarget ?? removalTarget.pushTarget const registeredWorktree = findRegisteredDeletableWorktree( @@ -11976,14 +12405,20 @@ export class OrcaRuntimeService { (path) => fsProvider.readFile(path) ) } else { + const access = getLocalWorktreePathAccess(localWorktreeGitOptions) canCleanOrphanedDirectory = await canSafelyRemoveOrphanedWorktreeDirectory( - removalTarget.path, - repo.path + toLocalWorktreeRuntimePath(removalTarget.path, localWorktreeGitOptions), + toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), + access.statPath, + access.readPath ) } } if (canCleanOrphanedDirectory) { - assertWorktreeDoesNotContainRegisteredWorktree(removalTarget.path, registeredWorktrees) + assertWorktreeDoesNotContainRegisteredWorktree( + toLocalWorktreeRuntimePath(removalTarget.path, localWorktreeGitOptions), + registeredWorktrees + ) if (!force) { throw new Error(ORPHANED_WORKTREE_DIRECTORY_MESSAGE) } @@ -11997,7 +12432,7 @@ export class OrcaRuntimeService { store ) } else { - await rm(removalTarget.path, { recursive: true, force: true }) + await removeLocalWorktreePath(removalTarget.path, localWorktreeGitOptions) await cleanupUnusedWorktreePushTargetRemote( repo.path, removalTarget.id, @@ -12013,7 +12448,7 @@ export class OrcaRuntimeService { this.notifyWorktreesChanged(repo.id) return {} } - if (await isRuntimeWorktreePathMissing(repo, removalTarget.path)) { + if (await isRuntimeWorktreePathMissing(repo, removalTarget.path, localWorktreeGitOptions)) { if (!force && !removedMeta) { // Why: without persisted metadata, require the renderer recovery // path before deleting Orca-only state for an unregistered path. @@ -12080,7 +12515,13 @@ export class OrcaRuntimeService { const hooks = getEffectiveHooks(repo) let warning: string | undefined if (hooks?.scripts.archive && runHooks) { - const result = await runHook('archive', canonicalWorktreePath, repo) + const result = await runHook( + 'archive', + canonicalWorktreePath, + repo, + undefined, + this.getLocalGitExecutionOptionArgs(repo)[0] + ) if (!result.success) { console.error(`[hooks] archive hook failed for ${canonicalWorktreePath}:`, result.output) } @@ -12092,7 +12533,9 @@ export class OrcaRuntimeService { let shouldTearDownPtys = true try { - await assertWorktreeCleanForRemoval(canonicalWorktreePath, force) + await (hasLocalWorktreeGitOptions + ? assertWorktreeCleanForRemoval(canonicalWorktreePath, force, localWorktreeGitOptions) + : assertWorktreeCleanForRemoval(canonicalWorktreePath, force)) } catch (error) { if (!isOrphanCompatiblePreflightError(error)) { throw new Error(formatWorktreeRemovalError(error, canonicalWorktreePath, force)) @@ -12135,19 +12578,34 @@ export class OrcaRuntimeService { let removalResult: RemoveWorktreeResult | undefined try { + const removeOptions = hasLocalWorktreeGitOptions + ? { ...(!deleteBranch ? { deleteBranch } : {}), ...localWorktreeGitOptions } + : !deleteBranch + ? { deleteBranch } + : undefined removalResult = this.preserveBranchHeadFallback( - await (deleteBranch - ? removeWorktree(repo.path, canonicalWorktreePath, force) - : removeWorktree(repo.path, canonicalWorktreePath, force, { deleteBranch })), + await (removeOptions + ? removeWorktree(repo.path, canonicalWorktreePath, force, removeOptions) + : removeWorktree(repo.path, canonicalWorktreePath, force)), registeredWorktree.head ) } catch (error) { if (isOrphanedWorktreeError(error)) { - if (await canSafelyRemoveOrphanedWorktreeDirectory(canonicalWorktreePath, repo.path)) { + const access = getLocalWorktreePathAccess(localWorktreeGitOptions) + if ( + await canSafelyRemoveOrphanedWorktreeDirectory( + toLocalWorktreeRuntimePath(canonicalWorktreePath, localWorktreeGitOptions), + toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), + access.statPath, + access.readPath + ) + ) { await closeLocalWatcherForWorktreePath(canonicalWorktreePath).catch((err) => { console.warn(`[filesystem-watcher] failed to close ${canonicalWorktreePath}:`, err) }) - await rm(canonicalWorktreePath, { recursive: true, force: true }).catch(() => {}) + await removeLocalWorktreePath(canonicalWorktreePath, localWorktreeGitOptions).catch( + () => {} + ) } else { console.warn( `[worktrees] Refusing recursive cleanup for unproven worktree directory: ${canonicalWorktreePath}` @@ -12157,12 +12615,16 @@ export class OrcaRuntimeService { // (`.git/worktrees/`) is still intact. Without pruning, `git worktree // list` continues to show the stale entry and the branch it had checked out // remains locked — other worktrees cannot check it out. - await gitExecFileAsync(['worktree', 'prune'], { cwd: repo.path }).catch(() => {}) + await gitExecFileAsync( + ['worktree', 'prune'], + getLocalProjectGitExecOptions(this.requireStore(), repo) + ).catch(() => {}) await cleanupUnusedWorktreePushTargetRemote( repo.path, removalTarget.id, removedPushTarget, - store + store, + localWorktreeGitOptions ) this.clearOptimisticReconcileToken(removalTarget.id) this.removeWorktreeMetadataAndHistory(store, removalTarget.id) @@ -12181,7 +12643,8 @@ export class OrcaRuntimeService { repo.path, removalTarget.id, removedPushTarget, - store + store, + localWorktreeGitOptions ) this.rememberPreservedBranchCleanupTarget( removalTarget.id, @@ -12539,7 +13002,7 @@ export class OrcaRuntimeService { const repo = this.store.getRepo(worktree.repoId) // Why: mobile may be running on iOS while the actual terminal shell is // Windows/macOS/Linux or an SSH Linux host; quote for the host shell. - const platform = repo ? getAgentLaunchPlatformForRepo(repo) : process.platform + const platform = repo ? this.getAgentLaunchPlatformForRepo(repo) : process.platform const startupPlan = buildAgentStartupPlan({ agent: opts.agent, prompt: '', diff --git a/src/main/runtime/orchestration/db.test.ts b/src/main/runtime/orchestration/db.test.ts index ae4393b2389..ea2ff6f29a6 100644 --- a/src/main/runtime/orchestration/db.test.ts +++ b/src/main/runtime/orchestration/db.test.ts @@ -8,7 +8,7 @@ import { OrchestrationDb } from './db' import type { MessageType } from './db' describe('OrchestrationDb', () => { - let db: OrchestrationDb + let db: OrchestrationDb | undefined afterEach(() => { db?.close() @@ -680,6 +680,10 @@ describe('OrchestrationDb', () => { let tempDir: string afterEach(() => { + // Why: Windows keeps the SQLite file locked until the DB handle closes, + // so migration temp directories must close before recursive cleanup. + db?.close() + db = undefined if (tempDir) { rmSync(tempDir, { recursive: true, force: true }) } diff --git a/src/main/runtime/orchestration/preamble.test.ts b/src/main/runtime/orchestration/preamble.test.ts index a40ab3f2c99..5977cf5ad3a 100644 --- a/src/main/runtime/orchestration/preamble.test.ts +++ b/src/main/runtime/orchestration/preamble.test.ts @@ -38,25 +38,29 @@ describe('buildDispatchPreamble', () => { expect(result).toContain('--report-path ""') }) - it('CLI examples parse as valid shell (bash -n on the extracted block)', () => { - const result = buildDispatchPreamble(baseParams()) - // Why: feeding `bash -n` the full preamble falsely fails on apostrophes - // in the surrounding prose. Slice between the CLI markers and strip - // shell-style comment lines so we only syntax-check the commands. - const cliStart = result.indexOf('=== CLI COMMANDS ===') - const cliEnd = result.indexOf('=== AFTER YOU SEND worker_done ===') - expect(cliStart).toBeGreaterThan(-1) - expect(cliEnd).toBeGreaterThan(cliStart) - const block = result.slice(cliStart, cliEnd) - const stripped = block - .split('\n') - .filter((line) => !line.trim().startsWith('#')) - .filter((line) => !line.trim().startsWith('===')) - .join('\n') + it( + 'CLI examples parse as valid shell (bash -n on the extracted block)', + { timeout: 15_000 }, + () => { + const result = buildDispatchPreamble(baseParams()) + // Why: feeding `bash -n` the full preamble falsely fails on apostrophes + // in the surrounding prose. Slice between the CLI markers and strip + // shell-style comment lines so we only syntax-check the commands. + const cliStart = result.indexOf('=== CLI COMMANDS ===') + const cliEnd = result.indexOf('=== AFTER YOU SEND worker_done ===') + expect(cliStart).toBeGreaterThan(-1) + expect(cliEnd).toBeGreaterThan(cliStart) + const block = result.slice(cliStart, cliEnd) + const stripped = block + .split('\n') + .filter((line) => !line.trim().startsWith('#')) + .filter((line) => !line.trim().startsWith('===')) + .join('\n') - const check = spawnSync('bash', ['-n'], { input: stripped, encoding: 'utf8' }) - expect(check.status).toBe(0) - }) + const check = spawnSync('bash', ['-n'], { input: stripped, encoding: 'utf8' }) + expect(check.status).toBe(0) + } + ) it('includes heartbeat CLI block with taskId and dispatchId and 5-minute cadence', () => { const result = buildDispatchPreamble(baseParams()) diff --git a/src/main/runtime/remote-runtime-request-connection.integration.test.ts b/src/main/runtime/remote-runtime-request-connection.integration.test.ts index e7cb3a365d5..1d7d144df3f 100644 --- a/src/main/runtime/remote-runtime-request-connection.integration.test.ts +++ b/src/main/runtime/remote-runtime-request-connection.integration.test.ts @@ -16,12 +16,78 @@ import type { OrcaRuntimeService } from './orca-runtime' import { OrcaRuntimeRpcServer } from './runtime-rpc' import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../shared/protocol-version' +const REMOTE_RUNTIME_TEST_TIMEOUT_MS = 15_000 +const REMOTE_RUNTIME_REQUEST_TIMEOUT_MS = 5_000 + describe('remote runtime request connection integration', () => { - it('fetches repos through the real E2EE WebSocket runtime', async () => { - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-request-')) - const repoPath = join(userDataPath, 'repo') - const repos: Repo[] = [ - { + it( + 'fetches repos through the real E2EE WebSocket runtime', + { timeout: REMOTE_RUNTIME_TEST_TIMEOUT_MS }, + async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-request-')) + const repoPath = join(userDataPath, 'repo') + const repos: Repo[] = [ + { + id: 'repo-1', + path: repoPath, + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1, + hookSettings: getDefaultRepoHookSettings(), + worktreeBaseRef: 'main', + kind: 'git' + } + ] + const runtime = { + getRuntimeId: () => 'fetch-runtime-test', + getStartedAt: () => 1, + cleanupSubscriptionsForConnection: () => {}, + cancelMobileDictationForConnection: () => {}, + onClientDisconnected: () => {}, + listRepos: () => repos + } as unknown as OrcaRuntimeService + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + + await server.start() + try { + const offer = server.createPairingOffer({ name: 'integration', scope: 'runtime' }) + if (!offer.available) { + throw new Error('pairing unavailable') + } + const pairing = parsePairingCode(offer.pairingUrl) + if (!pairing) { + throw new Error('invalid pairing') + } + const connection = new RemoteRuntimeRequestConnection(pairing) + try { + await expect( + connection.request('repo.list', undefined, REMOTE_RUNTIME_REQUEST_TIMEOUT_MS) + ).resolves.toMatchObject({ + ok: true, + result: { repos } + }) + } finally { + connection.close() + } + } finally { + await server.stop() + rmSync(userDataPath, { recursive: true, force: true }) + } + } + ) + + it( + 'streams server worktree changes to another remote client', + { timeout: REMOTE_RUNTIME_TEST_TIMEOUT_MS }, + async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-request-events-')) + const repoPath = join(userDataPath, 'repo') + const repo: Repo = { id: 'repo-1', path: repoPath, displayName: 'repo', @@ -31,446 +97,413 @@ describe('remote runtime request connection integration', () => { worktreeBaseRef: 'main', kind: 'git' } - ] - const runtime = { - getRuntimeId: () => 'runtime-test', - getStartedAt: () => 1, - cleanupSubscriptionsForConnection: () => {}, - cancelMobileDictationForConnection: () => {}, - onClientDisconnected: () => {}, - listRepos: () => repos - } as unknown as OrcaRuntimeService - const server = new OrcaRuntimeRpcServer({ - runtime, - userDataPath, - enableWebSocket: true, - wsPort: 0 - }) - - await server.start() - try { - const offer = server.createPairingOffer({ name: 'integration', scope: 'runtime' }) - if (!offer.available) { - throw new Error('pairing unavailable') - } - const pairing = parsePairingCode(offer.pairingUrl) - if (!pairing) { - throw new Error('invalid pairing') - } - const connection = new RemoteRuntimeRequestConnection(pairing) - try { - await expect(connection.request('repo.list', undefined, 1000)).resolves.toMatchObject({ - ok: true, - result: { repos } - }) - } finally { - connection.close() - } - } finally { - await server.stop() - rmSync(userDataPath, { recursive: true, force: true }) - } - }) - - it('streams server worktree changes to another remote client', async () => { - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-request-events-')) - const repoPath = join(userDataPath, 'repo') - const repo: Repo = { - id: 'repo-1', - path: repoPath, - displayName: 'repo', - badgeColor: 'blue', - addedAt: 1, - hookSettings: getDefaultRepoHookSettings(), - worktreeBaseRef: 'main', - kind: 'git' - } - const worktrees: unknown[] = [ - { - id: 'repo-1::main', - repoId: repo.id, - path: repoPath, - branch: 'main', - displayName: 'repo', - isMainWorktree: true - } - ] - const clientEventListeners = new Set<(event: RuntimeClientEvent) => void>() - const subscriptionCleanups = new Map void>() - const runtime = { - getRuntimeId: () => 'runtime-test', - getStartedAt: () => 1, - cleanupSubscriptionsForConnection: (connectionId: string) => { - for (const [id, cleanup] of subscriptionCleanups) { - if (id.includes(connectionId)) { - cleanup() - subscriptionCleanups.delete(id) - } - } - }, - registerSubscriptionCleanup: (id: string, cleanup: () => void) => { - subscriptionCleanups.set(id, cleanup) - }, - cleanupSubscription: (id: string) => { - subscriptionCleanups.get(id)?.() - subscriptionCleanups.delete(id) - }, - cancelMobileDictationForConnection: () => {}, - onClientDisconnected: () => {}, - onClientEvent: (listener: (event: RuntimeClientEvent) => void) => { - clientEventListeners.add(listener) - return () => clientEventListeners.delete(listener) - }, - listDetectedManagedWorktrees: () => ({ - repoId: repo.id, - authoritative: true, - source: 'git', - worktrees - }), - createManagedWorktree: ({ name }: { name?: string }) => { - const worktree = { - id: `repo-1::${name || 'created'}`, - repoId: repo.id, - path: join(userDataPath, name || 'created'), - branch: name || 'created', - displayName: name || 'created', - isMainWorktree: false - } - worktrees.push(worktree) - for (const listener of clientEventListeners) { - listener({ type: 'worktreesChanged', repoId: repo.id }) - } - return { worktree } - } - } as unknown as OrcaRuntimeService - const server = new OrcaRuntimeRpcServer({ - runtime, - userDataPath, - enableWebSocket: true, - wsPort: 0 - }) - - await server.start() - try { - const offer = server.createPairingOffer({ name: 'integration', scope: 'runtime' }) - if (!offer.available) { - throw new Error('pairing unavailable') - } - const pairing = parsePairingCode(offer.pairingUrl) - if (!pairing) { - throw new Error('invalid pairing') - } - - const events: RuntimeClientEventStreamMessage[] = [] - const subscription = await subscribeRemoteRuntimeRequest( - pairing, - 'runtime.clientEvents.subscribe', - undefined, - 1000, + const worktrees: unknown[] = [ { - onResponse: (response) => { - if (response.ok) { - events.push(response.result) - } - }, - onError: (error) => { - throw error - } - } - ) - const desktop = new RemoteRuntimeRequestConnection(pairing) - const mobile = new RemoteRuntimeRequestConnection(pairing) - try { - await waitFor(() => events.some((event) => event.type === 'ready')) - - await expect( - desktop.request<{ worktrees: unknown[] }>( - 'worktree.detectedList', - { repo: repo.id }, - 1000 - ) - ).resolves.toMatchObject({ - ok: true, - result: { worktrees: [{ id: 'repo-1::main' }] } - }) - - await expect( - mobile.request('worktree.create', { repo: repo.id, name: 'mobile-created' }, 1000) - ).resolves.toMatchObject({ - ok: true, - result: { worktree: { id: 'repo-1::mobile-created' } } - }) - - await waitFor(() => - events.some((event) => event.type === 'worktreesChanged' && event.repoId === repo.id) - ) - await expect( - desktop.request<{ worktrees: unknown[] }>( - 'worktree.detectedList', - { repo: repo.id }, - 1000 - ) - ).resolves.toMatchObject({ - ok: true, - result: { - worktrees: [{ id: 'repo-1::main' }, { id: 'repo-1::mobile-created' }] - } - }) - } finally { - subscription.close() - desktop.close() - mobile.close() - } - } finally { - await server.stop() - rmSync(userDataPath, { recursive: true, force: true }) - } - }) - - it('multiplexes shared-control calls and passive subscriptions through the real runtime', async () => { - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-shared-control-')) - const repoPath = join(userDataPath, 'repo') - const repo: Repo = { - id: 'repo-1', - path: repoPath, - displayName: 'repo', - badgeColor: 'blue', - addedAt: 1, - hookSettings: getDefaultRepoHookSettings(), - worktreeBaseRef: 'main', - kind: 'git' - } - const worktrees: unknown[] = [ - { - id: 'repo-1::main', - repoId: repo.id, - path: repoPath, - branch: 'main', - displayName: 'repo', - isMainWorktree: true - } - ] - const clientEventListeners = new Set<(event: RuntimeClientEvent) => void>() - const accountsListeners = new Set<(snapshot: unknown) => void>() - const notificationListeners = new Set<(event: unknown) => void>() - const sessionTabListeners = new Set<(snapshot: unknown) => void>() - const subscriptionCleanups = new Map void>() - const sessionTabSnapshot = { - worktree: 'wt-1', - publicationEpoch: 'epoch-1', - snapshotVersion: 1, - activeGroupId: null, - activeTabId: null, - activeTabType: null, - tabs: [] - } - const runtime = { - getRuntimeId: () => 'runtime-test', - getStartedAt: () => 1, - getStatus: () => ({ - runtimeId: 'runtime-test', - startedAt: 1, - version: '1.0.0', - protocolVersion: 1, - minCompatibleDesktopVersion: '1.0.0', - minCompatibleMobileVersion: '1.0.0', - capabilities: [REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY] - }), - cleanupSubscriptionsForConnection: (connectionId: string) => { - for (const [id, cleanup] of Array.from(subscriptionCleanups)) { - if (id.includes(connectionId)) { - cleanup() - subscriptionCleanups.delete(id) - } - } - }, - registerSubscriptionCleanup: (id: string, cleanup: () => void) => { - subscriptionCleanups.set(id, cleanup) - }, - cleanupSubscription: (id: string) => { - subscriptionCleanups.get(id)?.() - subscriptionCleanups.delete(id) - }, - cleanupSubscriptionsByPrefix: (prefix: string) => { - for (const [id, cleanup] of Array.from(subscriptionCleanups)) { - if (id.startsWith(prefix)) { - cleanup() - subscriptionCleanups.delete(id) - } - } - }, - cancelMobileDictationForConnection: () => {}, - onClientDisconnected: () => {}, - onClientEvent: (listener: (event: RuntimeClientEvent) => void) => { - clientEventListeners.add(listener) - return () => clientEventListeners.delete(listener) - }, - getAccountsSnapshot: () => ({ claude: null, codex: null }), - refreshAccountsForMobile: async () => { - for (const listener of accountsListeners) { - listener({ claude: null, codex: null }) - } - }, - onAccountsChanged: (listener: (snapshot: unknown) => void) => { - accountsListeners.add(listener) - return () => accountsListeners.delete(listener) - }, - onNotificationDispatched: (listener: (event: unknown) => void) => { - notificationListeners.add(listener) - return () => notificationListeners.delete(listener) - }, - listMobileSessionTabs: () => sessionTabSnapshot, - listAllMobileSessionTabs: () => [sessionTabSnapshot], - onMobileSessionTabsChanged: (listener: (snapshot: unknown) => void) => { - sessionTabListeners.add(listener) - return () => sessionTabListeners.delete(listener) - }, - watchFileExplorer: async () => () => {}, - listRepos: () => [repo], - listDetectedManagedWorktrees: () => ({ - repoId: repo.id, - authoritative: true, - source: 'git', - worktrees - }), - createManagedWorktree: ({ name }: { name?: string }) => { - const worktree = { - id: `repo-1::${name || 'created'}`, + id: 'repo-1::main', repoId: repo.id, - path: join(userDataPath, name || 'created'), - branch: name || 'created', - displayName: name || 'created', - isMainWorktree: false + path: repoPath, + branch: 'main', + displayName: 'repo', + isMainWorktree: true } - worktrees.push(worktree) - for (const listener of clientEventListeners) { - listener({ type: 'worktreesChanged', repoId: repo.id }) - } - return { worktree } - } - } as unknown as OrcaRuntimeService - const server = new OrcaRuntimeRpcServer({ - runtime, - userDataPath, - enableWebSocket: true, - wsPort: 0 - }) - - await server.start() - try { - const offer = server.createPairingOffer({ name: 'integration', scope: 'runtime' }) - if (!offer.available) { - throw new Error('pairing unavailable') - } - const pairing = parsePairingCode(offer.pairingUrl) - if (!pairing) { - throw new Error('invalid pairing') - } - - const events: RuntimeClientEventStreamMessage[] = [] - const shared = new RemoteRuntimeSharedControlConnection(pairing) - const subscription = await shared.subscribe( - 'runtime.clientEvents.subscribe', - undefined, - 1000, - { - onResponse: (response) => { - if (response.ok) { - events.push(response.result) + ] + const clientEventListeners = new Set<(event: RuntimeClientEvent) => void>() + const subscriptionCleanups = new Map void>() + const runtime = { + getRuntimeId: () => 'events-runtime-test', + getStartedAt: () => 1, + cleanupSubscriptionsForConnection: (connectionId: string) => { + for (const [id, cleanup] of subscriptionCleanups) { + if (id.includes(connectionId)) { + cleanup() + subscriptionCleanups.delete(id) } - }, - onError: (error) => { - throw error } + }, + registerSubscriptionCleanup: (id: string, cleanup: () => void) => { + subscriptionCleanups.set(id, cleanup) + }, + cleanupSubscription: (id: string) => { + subscriptionCleanups.get(id)?.() + subscriptionCleanups.delete(id) + }, + cancelMobileDictationForConnection: () => {}, + onClientDisconnected: () => {}, + onClientEvent: (listener: (event: RuntimeClientEvent) => void) => { + clientEventListeners.add(listener) + return () => clientEventListeners.delete(listener) + }, + listDetectedManagedWorktrees: () => ({ + repoId: repo.id, + authoritative: true, + source: 'git', + worktrees + }), + createManagedWorktree: ({ name }: { name?: string }) => { + const worktree = { + id: `repo-1::${name || 'created'}`, + repoId: repo.id, + path: join(userDataPath, name || 'created'), + branch: name || 'created', + displayName: name || 'created', + isMainWorktree: false + } + worktrees.push(worktree) + for (const listener of clientEventListeners) { + listener({ type: 'worktreesChanged', repoId: repo.id }) + } + return { worktree } } - ) + } as unknown as OrcaRuntimeService + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + + await server.start() try { - await waitFor(() => events.some((event) => event.type === 'ready')) + const offer = server.createPairingOffer({ name: 'integration', scope: 'runtime' }) + if (!offer.available) { + throw new Error('pairing unavailable') + } + const pairing = parsePairingCode(offer.pairingUrl) + if (!pairing) { + throw new Error('invalid pairing') + } - await expect(shared.request('status.get', undefined, 1000)).resolves.toMatchObject({ - ok: true, - result: { capabilities: [REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY] } - }) - await expect(shared.request('repo.list', undefined, 1000)).resolves.toMatchObject({ - ok: true, - result: { repos: [repo] } - }) - await expect( - shared.request('worktree.create', { repo: repo.id, name: 'shared-created' }, 1000) - ).resolves.toMatchObject({ - ok: true, - result: { worktree: { id: 'repo-1::shared-created' } } - }) - await waitFor(() => - events.some((event) => event.type === 'worktreesChanged' && event.repoId === repo.id) - ) - - const mixedEvents: unknown[] = [] - const mixedMethods = [ - ['runtime.clientEvents.subscribe', undefined], - ['session.tabs.subscribe', { worktree: 'id:wt-1' }], - ['accounts.subscribe', undefined], - ['notifications.subscribe', undefined], - ['files.watch', { worktree: 'id:wt-1' }] - ] as const - const mixedSubscriptions = await Promise.all( - Array.from({ length: 30 }, (_value, index) => { - const [method, params] = mixedMethods[index % mixedMethods.length]! - return shared.subscribe(method, params, 1000, { - onResponse: (response) => { - if (response.ok) { - mixedEvents.push(response.result) - } - }, - onError: (error) => { - throw error + const events: RuntimeClientEventStreamMessage[] = [] + const subscription = await subscribeRemoteRuntimeRequest( + pairing, + 'runtime.clientEvents.subscribe', + undefined, + REMOTE_RUNTIME_REQUEST_TIMEOUT_MS, + { + onResponse: (response) => { + if (response.ok) { + events.push(response.result) } - }) + }, + onError: (error) => { + throw error + } + } + ) + const desktop = new RemoteRuntimeRequestConnection(pairing) + const mobile = new RemoteRuntimeRequestConnection(pairing) + try { + await waitFor(() => events.some((event) => event.type === 'ready')) + + await expect( + desktop.request<{ worktrees: unknown[] }>( + 'worktree.detectedList', + { repo: repo.id }, + REMOTE_RUNTIME_REQUEST_TIMEOUT_MS + ) + ).resolves.toMatchObject({ + ok: true, + result: { worktrees: [{ id: 'repo-1::main' }] } }) - ) - await waitFor( - () => - subscriptionCleanups.size >= mixedSubscriptions.length + 1 && mixedEvents.length > 0, - 5000, - () => `cleanup count ${subscriptionCleanups.size}, event count ${mixedEvents.length}` - ) - expect( - (server as unknown as { wsConnectionIds: Map }).wsConnectionIds.size - ).toBe(1) - for (const mixed of mixedSubscriptions) { - mixed.close() + + await expect( + mobile.request( + 'worktree.create', + { repo: repo.id, name: 'mobile-created' }, + REMOTE_RUNTIME_REQUEST_TIMEOUT_MS + ) + ).resolves.toMatchObject({ + ok: true, + result: { worktree: { id: 'repo-1::mobile-created' } } + }) + + await waitFor(() => + events.some((event) => event.type === 'worktreesChanged' && event.repoId === repo.id) + ) + await expect( + desktop.request<{ worktrees: unknown[] }>( + 'worktree.detectedList', + { repo: repo.id }, + REMOTE_RUNTIME_REQUEST_TIMEOUT_MS + ) + ).resolves.toMatchObject({ + ok: true, + result: { + worktrees: [{ id: 'repo-1::main' }, { id: 'repo-1::mobile-created' }] + } + }) + } finally { + subscription.close() + desktop.close() + mobile.close() + } + } finally { + await server.stop() + rmSync(userDataPath, { recursive: true, force: true }) + } + } + ) + + it( + 'multiplexes shared-control calls and passive subscriptions through the real runtime', + { timeout: REMOTE_RUNTIME_TEST_TIMEOUT_MS }, + async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-shared-control-')) + const repoPath = join(userDataPath, 'repo') + const repo: Repo = { + id: 'repo-1', + path: repoPath, + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1, + hookSettings: getDefaultRepoHookSettings(), + worktreeBaseRef: 'main', + kind: 'git' + } + const worktrees: unknown[] = [ + { + id: 'repo-1::main', + repoId: repo.id, + path: repoPath, + branch: 'main', + displayName: 'repo', + isMainWorktree: true + } + ] + const clientEventListeners = new Set<(event: RuntimeClientEvent) => void>() + const accountsListeners = new Set<(snapshot: unknown) => void>() + const notificationListeners = new Set<(event: unknown) => void>() + const sessionTabListeners = new Set<(snapshot: unknown) => void>() + const subscriptionCleanups = new Map void>() + const sessionTabSnapshot = { + worktree: 'wt-1', + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + const runtime = { + getRuntimeId: () => 'shared-runtime-test', + getStartedAt: () => 1, + getStatus: () => ({ + runtimeId: 'shared-runtime-test', + startedAt: 1, + version: '1.0.0', + protocolVersion: 1, + minCompatibleDesktopVersion: '1.0.0', + minCompatibleMobileVersion: '1.0.0', + capabilities: [REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY] + }), + cleanupSubscriptionsForConnection: (connectionId: string) => { + for (const [id, cleanup] of Array.from(subscriptionCleanups)) { + if (id.includes(connectionId)) { + cleanup() + subscriptionCleanups.delete(id) + } + } + }, + registerSubscriptionCleanup: (id: string, cleanup: () => void) => { + subscriptionCleanups.set(id, cleanup) + }, + cleanupSubscription: (id: string) => { + subscriptionCleanups.get(id)?.() + subscriptionCleanups.delete(id) + }, + cleanupSubscriptionsByPrefix: (prefix: string) => { + for (const [id, cleanup] of Array.from(subscriptionCleanups)) { + if (id.startsWith(prefix)) { + cleanup() + subscriptionCleanups.delete(id) + } + } + }, + cancelMobileDictationForConnection: () => {}, + onClientDisconnected: () => {}, + onClientEvent: (listener: (event: RuntimeClientEvent) => void) => { + clientEventListeners.add(listener) + return () => clientEventListeners.delete(listener) + }, + getAccountsSnapshot: () => ({ claude: null, codex: null }), + refreshAccountsForMobile: async () => { + for (const listener of accountsListeners) { + listener({ claude: null, codex: null }) + } + }, + onAccountsChanged: (listener: (snapshot: unknown) => void) => { + accountsListeners.add(listener) + return () => accountsListeners.delete(listener) + }, + onNotificationDispatched: (listener: (event: unknown) => void) => { + notificationListeners.add(listener) + return () => notificationListeners.delete(listener) + }, + listMobileSessionTabs: () => sessionTabSnapshot, + listAllMobileSessionTabs: () => [sessionTabSnapshot], + onMobileSessionTabsChanged: (listener: (snapshot: unknown) => void) => { + sessionTabListeners.add(listener) + return () => sessionTabListeners.delete(listener) + }, + watchFileExplorer: async () => () => {}, + listRepos: () => [repo], + listDetectedManagedWorktrees: () => ({ + repoId: repo.id, + authoritative: true, + source: 'git', + worktrees + }), + createManagedWorktree: ({ name }: { name?: string }) => { + const worktree = { + id: `repo-1::${name || 'created'}`, + repoId: repo.id, + path: join(userDataPath, name || 'created'), + branch: name || 'created', + displayName: name || 'created', + isMainWorktree: false + } + worktrees.push(worktree) + for (const listener of clientEventListeners) { + listener({ type: 'worktreesChanged', repoId: repo.id }) + } + return { worktree } + } + } as unknown as OrcaRuntimeService + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + + await server.start() + try { + const offer = server.createPairingOffer({ name: 'integration', scope: 'runtime' }) + if (!offer.available) { + throw new Error('pairing unavailable') + } + const pairing = parsePairingCode(offer.pairingUrl) + if (!pairing) { + throw new Error('invalid pairing') } - const extraSubscriptions = await Promise.all( - Array.from({ length: 30 }, () => - shared.subscribe( - 'runtime.clientEvents.subscribe', - undefined, - 1000, - { - onResponse: () => {}, + const events: RuntimeClientEventStreamMessage[] = [] + const shared = new RemoteRuntimeSharedControlConnection(pairing) + const subscription = await shared.subscribe( + 'runtime.clientEvents.subscribe', + undefined, + REMOTE_RUNTIME_REQUEST_TIMEOUT_MS, + { + onResponse: (response) => { + if (response.ok) { + events.push(response.result) + } + }, + onError: (error) => { + throw error + } + } + ) + try { + await waitFor(() => events.some((event) => event.type === 'ready')) + + await expect( + shared.request('status.get', undefined, REMOTE_RUNTIME_REQUEST_TIMEOUT_MS) + ).resolves.toMatchObject({ + ok: true, + result: { capabilities: [REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY] } + }) + await expect( + shared.request('repo.list', undefined, REMOTE_RUNTIME_REQUEST_TIMEOUT_MS) + ).resolves.toMatchObject({ + ok: true, + result: { repos: [repo] } + }) + await expect( + shared.request( + 'worktree.create', + { repo: repo.id, name: 'shared-created' }, + REMOTE_RUNTIME_REQUEST_TIMEOUT_MS + ) + ).resolves.toMatchObject({ + ok: true, + result: { worktree: { id: 'repo-1::shared-created' } } + }) + await waitFor(() => + events.some((event) => event.type === 'worktreesChanged' && event.repoId === repo.id) + ) + + const mixedEvents: unknown[] = [] + const mixedMethods = [ + ['runtime.clientEvents.subscribe', undefined], + ['session.tabs.subscribe', { worktree: 'id:wt-1' }], + ['accounts.subscribe', undefined], + ['notifications.subscribe', undefined], + ['files.watch', { worktree: 'id:wt-1' }] + ] as const + const mixedSubscriptions = await Promise.all( + Array.from({ length: 30 }, (_value, index) => { + const [method, params] = mixedMethods[index % mixedMethods.length]! + return shared.subscribe(method, params, REMOTE_RUNTIME_REQUEST_TIMEOUT_MS, { + onResponse: (response) => { + if (response.ok) { + mixedEvents.push(response.result) + } + }, onError: (error) => { throw error } - } + }) + }) + ) + await waitFor( + () => subscriptionCleanups.size >= mixedSubscriptions.length + 1, + 5000, + () => `cleanup count ${subscriptionCleanups.size}, event count ${mixedEvents.length}` + ) + await waitFor( + () => mixedEvents.length > 0, + REMOTE_RUNTIME_REQUEST_TIMEOUT_MS, + () => `cleanup count ${subscriptionCleanups.size}, event count ${mixedEvents.length}` + ) + expect( + (server as unknown as { wsConnectionIds: Map }).wsConnectionIds.size + ).toBe(1) + for (const mixed of mixedSubscriptions) { + mixed.close() + } + + const extraSubscriptions = await Promise.all( + Array.from({ length: 30 }, () => + shared.subscribe( + 'runtime.clientEvents.subscribe', + undefined, + REMOTE_RUNTIME_REQUEST_TIMEOUT_MS, + { + onResponse: () => {}, + onError: (error) => { + throw error + } + } + ) ) ) - ) - expect( - (server as unknown as { wsConnectionIds: Map }).wsConnectionIds.size - ).toBe(1) - for (const extra of extraSubscriptions) { - extra.close() + expect( + (server as unknown as { wsConnectionIds: Map }).wsConnectionIds.size + ).toBe(1) + for (const extra of extraSubscriptions) { + extra.close() + } + } finally { + subscription.close() + shared.close() } } finally { - subscription.close() - shared.close() + await server.stop() + rmSync(userDataPath, { recursive: true, force: true }) } - } finally { - await server.stop() - rmSync(userDataPath, { recursive: true, force: true }) } - }, 10_000) + ) }) async function waitFor( diff --git a/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts b/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts index ba874fda2da..5e986e6d129 100644 --- a/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts +++ b/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts @@ -34,6 +34,19 @@ const ProjectHostSetupClone = z.object({ displayName: OptionalString }) +const LocalWindowsRuntimePreference = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('inherit-global') }), + z.object({ kind: z.literal('windows-host') }), + z.object({ kind: z.literal('wsl'), distro: requiredString('Missing WSL distro') }) +]) + +const ProjectUpdate = z.object({ + projectId: requiredString('Missing project ID'), + updates: z.object({ + localWindowsRuntimePreference: LocalWindowsRuntimePreference.optional() + }) +}) + const ProjectHostSetupCreate = z.object({ projectId: requiredString('Missing project ID'), hostId: requiredString('Missing host ID').transform((value, ctx) => { @@ -79,6 +92,13 @@ export const PROJECT_RUNTIME_METHODS: RpcMethod[] = [ params: null, handler: (_params, { runtime }) => ({ projects: runtime.listProjects() }) }), + defineMethod({ + name: 'project.update', + params: ProjectUpdate, + handler: (params, { runtime }) => ({ + project: runtime.updateProject(params.projectId, params.updates) + }) + }), defineMethod({ name: 'projectHostSetup.list', params: null, diff --git a/src/main/runtime/rpc/methods/repo.test.ts b/src/main/runtime/rpc/methods/repo.test.ts index 53f1e704a76..3c1593d45e4 100644 --- a/src/main/runtime/rpc/methods/repo.test.ts +++ b/src/main/runtime/rpc/methods/repo.test.ts @@ -9,6 +9,40 @@ function makeRequest(method: string, params?: unknown): RpcRequest { } describe('repo RPC methods', () => { + it('updates project runtime preferences on the runtime server', async () => { + const project = { + id: 'project-1', + displayName: 'Project', + badgeColor: '#737373', + sourceRepoIds: [], + createdAt: 1, + updatedAt: 2, + localWindowsRuntimePreference: { kind: 'windows-host' } + } + const runtime = { + getRuntimeId: () => 'test-runtime', + updateProject: vi.fn().mockReturnValue(project) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('project.update', { + projectId: 'project-1', + updates: { localWindowsRuntimePreference: { kind: 'windows-host' } } + }) + ) + + expect(runtime.updateProject).toHaveBeenCalledWith('project-1', { + localWindowsRuntimePreference: { kind: 'windows-host' } + }) + expect(response).toMatchObject({ + ok: true, + result: { + project: { id: 'project-1', localWindowsRuntimePreference: { kind: 'windows-host' } } + } + }) + }) + it('creates a repo on the runtime server', async () => { const runtime = { getRuntimeId: () => 'test-runtime', diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index 21b15721733..ca932fbf0d7 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -568,7 +568,7 @@ describe('OrcaRuntimeRpcServer', () => { disconnectSpy.mockRestore() await server.stop() } - }) + }, 15_000) it('does not revoke runtime-scoped devices through mobile revocation', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) @@ -633,7 +633,7 @@ describe('OrcaRuntimeRpcServer', () => { } finally { await server.stop() } - }) + }, 15_000) it('rotates unused runtime pairing links without revoking already-used grants', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) @@ -689,7 +689,7 @@ describe('OrcaRuntimeRpcServer', () => { } finally { await server.stop() } - }) + }, 15_000) it('caps WebSocket long-polls and aborts them when the socket closes', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) diff --git a/src/main/runtime/runtime-socket-sweep.test.ts b/src/main/runtime/runtime-socket-sweep.test.ts index d2165039369..0c62841a5df 100644 --- a/src/main/runtime/runtime-socket-sweep.test.ts +++ b/src/main/runtime/runtime-socket-sweep.test.ts @@ -1,6 +1,6 @@ import { existsSync, mkdtempSync, writeFileSync } from 'fs' import { tmpdir } from 'os' -import { join } from 'path' +import { basename, join } from 'path' import { describe, expect, it } from 'vitest' import { createRuntimeTransportMetadata, @@ -63,8 +63,8 @@ describe('sweepOrphanedRuntimeSockets', () => { if (transport.kind !== 'unix') { throw new Error('expected unix transport') } - const basename = transport.endpoint.slice(transport.endpoint.lastIndexOf('/') + 1) - expect(RUNTIME_SOCKET_NAME_REGEX.test(basename)).toBe(true) + const socketName = basename(transport.endpoint) + expect(RUNTIME_SOCKET_NAME_REGEX.test(socketName)).toBe(true) }) it('regex invariant: also matches the fallback runtimeId suffix ("rt")', () => { @@ -76,8 +76,8 @@ describe('sweepOrphanedRuntimeSockets', () => { if (transport.kind !== 'unix') { throw new Error('expected unix transport') } - const basename = transport.endpoint.slice(transport.endpoint.lastIndexOf('/') + 1) - expect(RUNTIME_SOCKET_NAME_REGEX.test(basename)).toBe(true) - expect(basename).toBe('o-99-rt.sock') + const socketName = basename(transport.endpoint) + expect(RUNTIME_SOCKET_NAME_REGEX.test(socketName)).toBe(true) + expect(socketName).toBe('o-99-rt.sock') }) }) diff --git a/src/main/runtime/tls-certificate.ts b/src/main/runtime/tls-certificate.ts index 1972d13d16e..1a2f108272e 100644 --- a/src/main/runtime/tls-certificate.ts +++ b/src/main/runtime/tls-certificate.ts @@ -3,7 +3,7 @@ // cert is generated once on first run and reused across restarts. The mobile // app pins the certificate fingerprint received during QR pairing. import { createHash } from 'crypto' -import { execSync } from 'child_process' +import { execFileSync } from 'child_process' import { existsSync, readFileSync, chmodSync } from 'fs' import { join } from 'path' @@ -34,12 +34,33 @@ export function loadOrCreateTlsCertificate(userDataPath: string): TlsCertificate const keyPath_ = join(userDataPath, TLS_KEY_FILENAME) const certPath_ = join(userDataPath, TLS_CERT_FILENAME) - // Why: openssl is available on macOS, Linux, and Windows (via Git Bash). - // Using it avoids hand-rolling ASN.1 DER encoding which is error-prone. - execSync( - `openssl req -new -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 ` + - `-nodes -days 3650 -subj "/CN=Orca Runtime" ` + - `-keyout "${keyPath_}" -out "${certPath_}" 2>/dev/null` + // Why: argv-based spawning avoids shell redirection/quoting differences for + // Windows temp paths while keeping OpenSSL as the certificate generator. + const openSslConfigPath = resolveOpenSslConfigPath() + execFileSync( + resolveOpenSslExecutable(), + [ + 'req', + '-new', + '-x509', + '-newkey', + 'ec', + '-pkeyopt', + 'ec_paramgen_curve:prime256v1', + '-nodes', + '-days', + '3650', + '-subj', + '/CN=Orca Runtime', + '-keyout', + keyPath_, + '-out', + certPath_ + ], + { + env: openSslConfigPath ? { ...process.env, OPENSSL_CONF: openSslConfigPath } : process.env, + stdio: 'ignore' + } ) chmodSync(keyPath_, 0o600) @@ -50,6 +71,38 @@ export function loadOrCreateTlsCertificate(userDataPath: string): TlsCertificate return { cert, key, fingerprint: computeFingerprint(cert)! } } +function resolveOpenSslExecutable(): string { + if (process.platform !== 'win32') { + return 'openssl' + } + + const windowsCandidates = [ + 'C:\\Program Files\\Git\\usr\\bin\\openssl.exe', + 'C:\\Program Files\\Git\\mingw64\\bin\\openssl.exe', + 'C:\\Program Files (x86)\\Git\\usr\\bin\\openssl.exe', + 'C:\\Program Files (x86)\\Git\\mingw64\\bin\\openssl.exe' + ] + return windowsCandidates.find((candidate) => existsSync(candidate)) ?? 'openssl' +} + +function resolveOpenSslConfigPath(): string | null { + if (process.env.OPENSSL_CONF && existsSync(process.env.OPENSSL_CONF)) { + return null + } + + if (process.platform !== 'win32') { + return null + } + + const windowsConfigCandidates = [ + 'C:\\Program Files\\Git\\mingw64\\etc\\ssl\\openssl.cnf', + 'C:\\Program Files\\Git\\usr\\ssl\\openssl.cnf', + 'C:\\Program Files (x86)\\Git\\mingw64\\etc\\ssl\\openssl.cnf', + 'C:\\Program Files (x86)\\Git\\usr\\ssl\\openssl.cnf' + ] + return windowsConfigCandidates.find((candidate) => existsSync(candidate)) ?? null +} + function computeFingerprint(certPem: string): string | null { const derMatch = certPem.match( /-----BEGIN CERTIFICATE-----\n([\s\S]+?)\n-----END CERTIFICATE-----/ diff --git a/src/main/source-control/forge-provider.ts b/src/main/source-control/forge-provider.ts index f9214c965a3..32899c1b657 100644 --- a/src/main/source-control/forge-provider.ts +++ b/src/main/source-control/forge-provider.ts @@ -4,35 +4,42 @@ import type { HostedReviewInfo, HostedReviewProvider } from '../../shared/hosted-review' -import { hostedReviewInfoFromGitHubPRInfo } from '../../shared/hosted-review-github' -import type { MRInfo, PRInfo } from '../../shared/types' import { getAzureDevOpsPullRequest, getAzureDevOpsPullRequestForBranch, getAzureDevOpsRepoSlug } from '../azure-devops/client' import { createAzureDevOpsPullRequest } from '../azure-devops/pull-request-creation' -import type { AzureDevOpsPullRequestInfo } from '../azure-devops/pull-request-mappers' import { getBitbucketPullRequest, getBitbucketPullRequestForBranch, getBitbucketRepoSlug } from '../bitbucket/client' -import type { BitbucketPullRequestInfo } from '../bitbucket/pull-request-mappers' import { getGiteaPullRequest, getGiteaPullRequestForBranch, getGiteaRepoSlug } from '../gitea/client' import { createGiteaPullRequest } from '../gitea/pull-request-creation' -import type { GiteaPullRequestInfo } from '../gitea/pull-request-mappers' import { createGitHubPullRequest, getPRForBranch, getRepoSlug } from '../github/client' import { getMergeRequest, getMergeRequestForBranch, getProjectSlug } from '../gitlab/client' import { createGitLabMergeRequest } from '../gitlab/merge-request-creation' +import { + mapAzureDevOpsReview, + mapBitbucketReview, + mapGiteaReview, + mapGitHubReview, + mapGitLabReview +} from './forge-review-mappers' +import { + hasHostedReviewLocalGitOptions, + getHostedReviewLocalGitOptions, + type HostedReviewExecutionOptions +} from './hosted-review-git-options' export type ForgeProviderId = Exclude -export type ForgeProviderRepositoryContext = { +export type ForgeProviderRepositoryContext = HostedReviewExecutionOptions & { repoPath: string connectionId?: string | null } @@ -56,94 +63,41 @@ export type ForgeProvider = { createReview?( repoPath: string, input: CreateHostedReviewInput, - connectionId?: string | null + connectionId?: string | null, + options?: HostedReviewExecutionOptions ): Promise } -function mapGitHubReview(pr: PRInfo): HostedReviewInfo { - return hostedReviewInfoFromGitHubPRInfo(pr) -} - -function mapGitLabReviewState(state: MRInfo['state']): HostedReviewInfo['state'] { - if (state === 'opened' || state === 'locked') { - return 'open' - } - return state -} - -function mapGitLabReview(mr: MRInfo): HostedReviewInfo { - return { - provider: 'gitlab', - number: mr.number, - title: mr.title, - state: mapGitLabReviewState(mr.state), - url: mr.url, - status: mr.pipelineStatus, - updatedAt: mr.updatedAt, - mergeable: mr.mergeable, - ...(mr.headSha ? { headSha: mr.headSha } : {}), - ...(mr.baseRefName ? { baseRefName: mr.baseRefName } : {}), - ...(mr.conflictSummary ? { conflictSummary: mr.conflictSummary } : {}) - } -} - -function mapBitbucketReview(pr: BitbucketPullRequestInfo): HostedReviewInfo { - return { - provider: 'bitbucket', - number: pr.number, - title: pr.title, - state: pr.state, - url: pr.url, - status: pr.status, - updatedAt: pr.updatedAt, - mergeable: pr.mergeable, - ...(pr.headSha ? { headSha: pr.headSha } : {}) - } -} - -function mapAzureDevOpsReview(pr: AzureDevOpsPullRequestInfo): HostedReviewInfo { - return { - provider: 'azure-devops', - number: pr.number, - title: pr.title, - state: pr.state, - url: pr.url, - status: pr.status, - updatedAt: pr.updatedAt, - mergeable: pr.mergeable, - ...(pr.headSha ? { headSha: pr.headSha } : {}) - } -} - -function mapGiteaReview(pr: GiteaPullRequestInfo): HostedReviewInfo { - return { - provider: 'gitea', - number: pr.number, - title: pr.title, - state: pr.state, - url: pr.url, - status: pr.status, - updatedAt: pr.updatedAt, - mergeable: pr.mergeable, - ...(pr.headSha ? { headSha: pr.headSha } : {}) - } +function hostedReviewExecutionArgs( + options: HostedReviewExecutionOptions +): [] | [HostedReviewExecutionOptions] { + return hasHostedReviewLocalGitOptions(options) + ? [{ localGitExecOptions: getHostedReviewLocalGitOptions(options) }] + : [] } const gitLabForgeProvider = { id: 'gitlab', supportsReviewCreation: true, - resolveRepository: ({ repoPath, connectionId }) => getProjectSlug(repoPath, connectionId), + resolveRepository: (context) => + getProjectSlug(context.repoPath, context.connectionId, ...hostedReviewExecutionArgs(context)), async getReviewForBranch(input) { const mr = await getMergeRequestForBranch( input.repoPath, input.branch, input.linkedReviewNumber ?? null, - input.connectionId + input.connectionId, + ...hostedReviewExecutionArgs(input) ) return mr ? mapGitLabReview(mr) : null }, async getReviewByNumber(input) { - const mr = await getMergeRequest(input.repoPath, input.number, input.connectionId) + const mr = await getMergeRequest( + input.repoPath, + input.number, + input.connectionId, + ...hostedReviewExecutionArgs(input) + ) return mr ? mapGitLabReview(mr) : null }, createReview: createGitLabMergeRequest @@ -152,10 +106,12 @@ const gitLabForgeProvider = { const gitHubForgeProvider = { id: 'github', supportsReviewCreation: true, - resolveRepository: ({ repoPath, connectionId }) => getRepoSlug(repoPath, connectionId), + resolveRepository: (context) => + getRepoSlug(context.repoPath, context.connectionId, ...hostedReviewExecutionArgs(context)), async getReviewForBranch(input) { const fallbackReviewNumber = input.linkedReviewNumber == null ? (input.fallbackReviewNumber ?? null) : null + const executionArgs = hostedReviewExecutionArgs(input) const pr = fallbackReviewNumber !== null ? await getPRForBranch( @@ -163,18 +119,39 @@ const gitHubForgeProvider = { input.branch, input.linkedReviewNumber ?? null, input.connectionId, - fallbackReviewNumber - ) - : await getPRForBranch( - input.repoPath, - input.branch, - input.linkedReviewNumber ?? null, - input.connectionId + fallbackReviewNumber, + ...executionArgs ) + : executionArgs.length > 0 + ? await getPRForBranch( + input.repoPath, + input.branch, + input.linkedReviewNumber ?? null, + input.connectionId, + null, + ...executionArgs + ) + : await getPRForBranch( + input.repoPath, + input.branch, + input.linkedReviewNumber ?? null, + input.connectionId + ) return pr ? mapGitHubReview(pr) : null }, async getReviewByNumber(input) { - const pr = await getPRForBranch(input.repoPath, '', input.number, input.connectionId) + const executionArgs = hostedReviewExecutionArgs(input) + const pr = + executionArgs.length > 0 + ? await getPRForBranch( + input.repoPath, + '', + input.number, + input.connectionId, + null, + ...executionArgs + ) + : await getPRForBranch(input.repoPath, '', input.number, input.connectionId) return pr ? mapGitHubReview(pr) : null }, createReview: createGitHubPullRequest @@ -183,18 +160,29 @@ const gitHubForgeProvider = { const bitbucketForgeProvider = { id: 'bitbucket', supportsReviewCreation: false, - resolveRepository: ({ repoPath, connectionId }) => getBitbucketRepoSlug(repoPath, connectionId), + resolveRepository: (context) => + getBitbucketRepoSlug( + context.repoPath, + context.connectionId, + ...hostedReviewExecutionArgs(context) + ), async getReviewForBranch(input) { const pr = await getBitbucketPullRequestForBranch( input.repoPath, input.branch, input.linkedReviewNumber ?? null, - input.connectionId + input.connectionId, + ...hostedReviewExecutionArgs(input) ) return pr ? mapBitbucketReview(pr) : null }, async getReviewByNumber(input) { - const pr = await getBitbucketPullRequest(input.repoPath, input.number, input.connectionId) + const pr = await getBitbucketPullRequest( + input.repoPath, + input.number, + input.connectionId, + ...hostedReviewExecutionArgs(input) + ) return pr ? mapBitbucketReview(pr) : null } } satisfies ForgeProvider @@ -202,18 +190,29 @@ const bitbucketForgeProvider = { const azureDevOpsForgeProvider = { id: 'azure-devops', supportsReviewCreation: true, - resolveRepository: ({ repoPath, connectionId }) => getAzureDevOpsRepoSlug(repoPath, connectionId), + resolveRepository: (context) => + getAzureDevOpsRepoSlug( + context.repoPath, + context.connectionId, + ...hostedReviewExecutionArgs(context) + ), async getReviewForBranch(input) { const pr = await getAzureDevOpsPullRequestForBranch( input.repoPath, input.branch, input.linkedReviewNumber ?? null, - input.connectionId + input.connectionId, + ...hostedReviewExecutionArgs(input) ) return pr ? mapAzureDevOpsReview(pr) : null }, async getReviewByNumber(input) { - const pr = await getAzureDevOpsPullRequest(input.repoPath, input.number, input.connectionId) + const pr = await getAzureDevOpsPullRequest( + input.repoPath, + input.number, + input.connectionId, + ...hostedReviewExecutionArgs(input) + ) return pr ? mapAzureDevOpsReview(pr) : null }, createReview: createAzureDevOpsPullRequest @@ -222,18 +221,25 @@ const azureDevOpsForgeProvider = { const giteaForgeProvider = { id: 'gitea', supportsReviewCreation: true, - resolveRepository: ({ repoPath, connectionId }) => getGiteaRepoSlug(repoPath, connectionId), + resolveRepository: (context) => + getGiteaRepoSlug(context.repoPath, context.connectionId, ...hostedReviewExecutionArgs(context)), async getReviewForBranch(input) { const pr = await getGiteaPullRequestForBranch( input.repoPath, input.branch, input.linkedReviewNumber ?? null, - input.connectionId + input.connectionId, + ...hostedReviewExecutionArgs(input) ) return pr ? mapGiteaReview(pr) : null }, async getReviewByNumber(input) { - const pr = await getGiteaPullRequest(input.repoPath, input.number, input.connectionId) + const pr = await getGiteaPullRequest( + input.repoPath, + input.number, + input.connectionId, + ...hostedReviewExecutionArgs(input) + ) return pr ? mapGiteaReview(pr) : null }, createReview: createGiteaPullRequest diff --git a/src/main/source-control/forge-review-mappers.ts b/src/main/source-control/forge-review-mappers.ts new file mode 100644 index 00000000000..2e167c4078b --- /dev/null +++ b/src/main/source-control/forge-review-mappers.ts @@ -0,0 +1,74 @@ +import type { HostedReviewInfo } from '../../shared/hosted-review' +import { hostedReviewInfoFromGitHubPRInfo } from '../../shared/hosted-review-github' +import type { MRInfo, PRInfo } from '../../shared/types' +import type { AzureDevOpsPullRequestInfo } from '../azure-devops/pull-request-mappers' +import type { BitbucketPullRequestInfo } from '../bitbucket/pull-request-mappers' +import type { GiteaPullRequestInfo } from '../gitea/pull-request-mappers' + +export function mapGitHubReview(pr: PRInfo): HostedReviewInfo { + return hostedReviewInfoFromGitHubPRInfo(pr) +} + +function mapGitLabReviewState(state: MRInfo['state']): HostedReviewInfo['state'] { + if (state === 'opened' || state === 'locked') { + return 'open' + } + return state +} + +export function mapGitLabReview(mr: MRInfo): HostedReviewInfo { + return { + provider: 'gitlab', + number: mr.number, + title: mr.title, + state: mapGitLabReviewState(mr.state), + url: mr.url, + status: mr.pipelineStatus, + updatedAt: mr.updatedAt, + mergeable: mr.mergeable, + ...(mr.headSha ? { headSha: mr.headSha } : {}), + ...(mr.conflictSummary ? { conflictSummary: mr.conflictSummary } : {}) + } +} + +export function mapBitbucketReview(pr: BitbucketPullRequestInfo): HostedReviewInfo { + return { + provider: 'bitbucket', + number: pr.number, + title: pr.title, + state: pr.state, + url: pr.url, + status: pr.status, + updatedAt: pr.updatedAt, + mergeable: pr.mergeable, + ...(pr.headSha ? { headSha: pr.headSha } : {}) + } +} + +export function mapAzureDevOpsReview(pr: AzureDevOpsPullRequestInfo): HostedReviewInfo { + return { + provider: 'azure-devops', + number: pr.number, + title: pr.title, + state: pr.state, + url: pr.url, + status: pr.status, + updatedAt: pr.updatedAt, + mergeable: pr.mergeable, + ...(pr.headSha ? { headSha: pr.headSha } : {}) + } +} + +export function mapGiteaReview(pr: GiteaPullRequestInfo): HostedReviewInfo { + return { + provider: 'gitea', + number: pr.number, + title: pr.title, + state: pr.state, + url: pr.url, + status: pr.status, + updatedAt: pr.updatedAt, + mergeable: pr.mergeable, + ...(pr.headSha ? { headSha: pr.headSha } : {}) + } +} diff --git a/src/main/source-control/hosted-review-creation.test.ts b/src/main/source-control/hosted-review-creation.test.ts index 77a3d60b8e2..aa0c0a32737 100644 --- a/src/main/source-control/hosted-review-creation.test.ts +++ b/src/main/source-control/hosted-review-creation.test.ts @@ -295,6 +295,61 @@ describe('createHostedReview', () => { expect(createGitHubPullRequestMock).toHaveBeenCalledOnce() }) + it('routes local WSL git and GitHub review creation through the selected runtime', async () => { + await expect( + createHostedReview( + '/repo', + { + provider: 'github', + base: 'main', + head: 'feature', + title: 'Feature' + }, + null, + { localGitExecOptions: { wslDistro: 'Ubuntu' } } + ) + ).resolves.toEqual({ + ok: true, + number: 12, + url: 'https://github.com/acme/orca/pull/12' + }) + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['rev-parse', '--abbrev-ref', 'HEAD'], { + cwd: '/repo', + wslDistro: 'Ubuntu' + }) + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['status', '--porcelain'], + expect.objectContaining({ cwd: '/repo', wslDistro: 'Ubuntu' }) + ) + expect(getUpstreamStatusMock).toHaveBeenCalledWith('/repo', undefined, { + wslDistro: 'Ubuntu' + }) + expect(getProjectSlugMock).toHaveBeenCalledWith('/repo', null, { + localGitExecOptions: { wslDistro: 'Ubuntu' } + }) + expect(getRepoSlugMock).toHaveBeenCalledWith('/repo', null, { + localGitExecOptions: { wslDistro: 'Ubuntu' } + }) + expect(getHostedReviewForBranchMock).toHaveBeenCalledWith( + expect.objectContaining({ + repoPath: '/repo', + branch: 'feature', + localGitExecOptions: { wslDistro: 'Ubuntu' } + }) + ) + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + ['auth', 'status', '--hostname', 'github.com'], + { cwd: '/repo', wslDistro: 'Ubuntu' } + ) + expect(createGitHubPullRequestMock).toHaveBeenCalledWith( + '/repo', + expect.objectContaining({ provider: 'github', head: 'feature' }), + null, + { localGitExecOptions: { wslDistro: 'Ubuntu' } } + ) + }) + it('creates a GitLab merge request after fresh main-process validation passes', async () => { mockGitLabProvider() diff --git a/src/main/source-control/hosted-review-creation.ts b/src/main/source-control/hosted-review-creation.ts index 7b38d7c073b..90159a3dac4 100644 --- a/src/main/source-control/hosted-review-creation.ts +++ b/src/main/source-control/hosted-review-creation.ts @@ -34,24 +34,36 @@ import { import { getSshGitProvider } from '../providers/ssh-git-dispatch' import { detectHostedReviewProvider, getForgeProviderForRepository } from './forge-provider' import { getHostedReviewForBranch } from './hosted-review' +import { + getHostedReviewLocalGitOptions, + type HostedReviewExecutionOptions +} from './hosted-review-git-options' type HostedReviewCreationEligibilityInput = HostedReviewCreationEligibilityArgs & { connectionId?: string | null -} +} & HostedReviewExecutionOptions function stripRefPrefix(ref: string): string { return normalizeHostedReviewHeadRef(ref) } +function hostedReviewExecutionContext( + options: HostedReviewExecutionOptions = {} +): HostedReviewExecutionOptions { + const localGitExecOptions = getHostedReviewLocalGitOptions(options) + return Object.keys(localGitExecOptions).length > 0 ? { localGitExecOptions } : {} +} + async function isGitHubAuthenticated( repoPath: string, - connectionId?: string | null + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} ): Promise { await acquire() try { await ghExecFileAsync( ['auth', 'status', '--hostname', 'github.com'], - connectionId ? {} : { cwd: repoPath } + connectionId ? {} : { cwd: repoPath, ...getHostedReviewLocalGitOptions(options) } ) return true } catch { @@ -63,18 +75,19 @@ async function isGitHubAuthenticated( async function isGitLabAuthenticated( repoPath: string, - connectionId?: string | null + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} ): Promise { - const projectRef = await getProjectSlug(repoPath, connectionId) + const projectRef = await getProjectSlug(repoPath, connectionId, options) if (!projectRef) { return false } await acquireGlab() try { - await glabExecFileAsync( - ['auth', 'status', '--hostname', projectRef.host], - glabRepoExecOptions(repoPath, connectionId) - ) + await glabExecFileAsync(['auth', 'status', '--hostname', projectRef.host], { + ...glabRepoExecOptions(repoPath, connectionId), + ...(connectionId ? {} : getHostedReviewLocalGitOptions(options)) + }) return true } catch { return false @@ -86,7 +99,8 @@ async function isGitLabAuthenticated( async function runGitForHostedReview( repoPath: string, args: string[], - connectionId?: string | null + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} ): Promise<{ stdout: string; stderr?: string }> { if (connectionId) { const provider = getSshGitProvider(connectionId) @@ -97,28 +111,37 @@ async function runGitForHostedReview( } return provider.exec(args, repoPath) } - return gitExecFileAsync(args, { cwd: repoPath }) + return gitExecFileAsync(args, { cwd: repoPath, ...getHostedReviewLocalGitOptions(options) }) } async function getDefaultBaseRef( repoPath: string, - connectionId?: string | null + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} ): Promise { - return resolveDefaultBaseRefViaExec((argv) => runGitForHostedReview(repoPath, argv, connectionId)) + return resolveDefaultBaseRefViaExec((argv) => + runGitForHostedReview(repoPath, argv, connectionId, options) + ) } -async function getCurrentBranch(repoPath: string, connectionId?: string | null): Promise { +async function getCurrentBranch( + repoPath: string, + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} +): Promise { const { stdout } = await runGitForHostedReview( repoPath, ['rev-parse', '--abbrev-ref', 'HEAD'], - connectionId + connectionId, + options ) return stripRefPrefix(stdout.trim()) } async function hasUncommittedChanges( repoPath: string, - connectionId?: string | null + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} ): Promise { if (connectionId) { const provider = getSshGitProvider(connectionId) @@ -133,6 +156,7 @@ async function hasUncommittedChanges( } const { stdout } = await gitExecFileAsync(['status', '--porcelain'], { cwd: repoPath, + ...getHostedReviewLocalGitOptions(options), // Why: create-PR validation should not take Git's optional index lock while // the user may be running fetch/pull/rebase from a terminal. env: gitOptionalLocksDisabledEnv() @@ -142,10 +166,11 @@ async function hasUncommittedChanges( async function getHostedReviewUpstreamStatus( repoPath: string, - connectionId?: string | null + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} ): Promise { if (!connectionId) { - return getUpstreamStatus(repoPath) + return getUpstreamStatus(repoPath, undefined, getHostedReviewLocalGitOptions(options)) } const provider = getSshGitProvider(connectionId) if (!provider) { @@ -204,10 +229,11 @@ function reviewCopy(provider: HostedReviewProvider): { async function isProviderAuthenticated( provider: HostedReviewCreationProvider, repoPath: string, - connectionId?: string | null + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} ): Promise { if (provider === 'gitlab') { - return isGitLabAuthenticated(repoPath, connectionId) + return isGitLabAuthenticated(repoPath, connectionId, options) } if (provider === 'azure-devops') { return isAzureDevOpsReviewCreationAuthenticated() @@ -215,7 +241,7 @@ async function isProviderAuthenticated( if (provider === 'gitea') { return isGiteaReviewCreationAuthenticated() } - return isGitHubAuthenticated(repoPath, connectionId) + return isGitHubAuthenticated(repoPath, connectionId, options) } function blockedCreateResultForReason( @@ -304,10 +330,11 @@ function blockedEligibilityToCreateResult( async function validateCurrentBranchCanCreateReview( repoPath: string, connectionId: string | null | undefined, - input: CreateHostedReviewInput + input: CreateHostedReviewInput, + options: HostedReviewExecutionOptions = {} ): Promise { const requestedHead = input.head ? stripRefPrefix(input.head).trim() : '' - const currentBranch = await getCurrentBranch(repoPath, connectionId) + const currentBranch = await getCurrentBranch(repoPath, connectionId, options) const copy = reviewCopy(input.provider) if (requestedHead && requestedHead !== currentBranch) { return { @@ -319,8 +346,8 @@ async function validateCurrentBranchCanCreateReview( try { const [dirty, upstreamStatus] = await Promise.all([ - hasUncommittedChanges(repoPath, connectionId), - getHostedReviewUpstreamStatus(repoPath, connectionId) + hasUncommittedChanges(repoPath, connectionId, options), + getHostedReviewUpstreamStatus(repoPath, connectionId, options) ]) const eligibility = await getHostedReviewCreationEligibility({ repoPath, @@ -330,7 +357,8 @@ async function validateCurrentBranchCanCreateReview( hasUpstream: upstreamStatus.hasUpstream, ahead: upstreamStatus.ahead, behind: upstreamStatus.behind, - connectionId + connectionId, + ...options }) // Why: renderer eligibility can be stale by submit time; the main process // is the last chance to avoid creating a PR from an out-of-date remote head. @@ -351,10 +379,11 @@ export async function getHostedReviewCreationEligibility( const branch = stripRefPrefix(args.branch).trim() const provider = await detectHostedReviewProvider({ repoPath: args.repoPath, - connectionId: args.connectionId + connectionId: args.connectionId, + ...hostedReviewExecutionContext(args) }) const defaultBaseRef = - args.base?.trim() || (await getDefaultBaseRef(args.repoPath, args.connectionId)) + args.base?.trim() || (await getDefaultBaseRef(args.repoPath, args.connectionId, args)) const baseBranch = defaultBaseRef ? normalizeHostedReviewBaseRef(defaultBaseRef) : null const review = await getHostedReviewForBranch({ repoPath: args.repoPath, @@ -365,7 +394,8 @@ export async function getHostedReviewCreationEligibility( linkedBitbucketPR: args.linkedBitbucketPR ?? null, linkedAzureDevOpsPR: args.linkedAzureDevOpsPR ?? null, linkedGiteaPR: args.linkedGiteaPR ?? null, - connectionId: args.connectionId ?? null + connectionId: args.connectionId ?? null, + ...hostedReviewExecutionContext(args) }) const baseResult = { @@ -409,7 +439,12 @@ export async function getHostedReviewCreationEligibility( if ((args.behind ?? 0) > 0) { return { ...baseResult, canCreate: false, blockedReason: 'needs_sync', nextAction: 'sync' } } - const authenticated = await isProviderAuthenticated(provider, args.repoPath, args.connectionId) + const authenticated = await isProviderAuthenticated( + provider, + args.repoPath, + args.connectionId, + args + ) if (!authenticated) { return { ...baseResult, @@ -427,7 +462,8 @@ export async function getHostedReviewCreationEligibility( export async function createHostedReview( repoPath: string, input: CreateHostedReviewInput, - connectionId?: string | null + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} ): Promise { if (!supportsHostedReviewCreation(input.provider)) { return { @@ -436,7 +472,11 @@ export async function createHostedReview( error: 'Creating reviews for this provider is not supported yet.' } } - const provider = await getForgeProviderForRepository({ repoPath, connectionId }) + const provider = await getForgeProviderForRepository({ + repoPath, + connectionId, + ...hostedReviewExecutionContext(options) + }) if (provider?.id !== input.provider || !provider.createReview) { const copy = reviewCopy(input.provider) return { @@ -445,9 +485,12 @@ export async function createHostedReview( error: `Creating ${copy.reviewLabel}s requires a ${copy.providerName} remote.` } } - const blocked = await validateCurrentBranchCanCreateReview(repoPath, connectionId, input) + const blocked = await validateCurrentBranchCanCreateReview(repoPath, connectionId, input, options) if (blocked) { return blocked } - return provider.createReview(repoPath, input, connectionId) + const localGitOptions = getHostedReviewLocalGitOptions(options) + return Object.keys(localGitOptions).length > 0 + ? provider.createReview(repoPath, input, connectionId, options) + : provider.createReview(repoPath, input, connectionId) } diff --git a/src/main/source-control/hosted-review-git-options.ts b/src/main/source-control/hosted-review-git-options.ts new file mode 100644 index 00000000000..40b0b6b0c90 --- /dev/null +++ b/src/main/source-control/hosted-review-git-options.ts @@ -0,0 +1,20 @@ +export type HostedReviewLocalGitOptions = { + wslDistro?: string +} + +export type HostedReviewExecutionOptions = { + localGitExecOptions?: HostedReviewLocalGitOptions +} + +export function getHostedReviewLocalGitOptions( + options: HostedReviewExecutionOptions = {} +): HostedReviewLocalGitOptions { + const wslDistro = options.localGitExecOptions?.wslDistro + return wslDistro ? { wslDistro } : {} +} + +export function hasHostedReviewLocalGitOptions( + options: HostedReviewExecutionOptions = {} +): boolean { + return Object.keys(getHostedReviewLocalGitOptions(options)).length > 0 +} diff --git a/src/main/source-control/hosted-review.test.ts b/src/main/source-control/hosted-review.test.ts index cf97faa5291..1bf1e4289fd 100644 --- a/src/main/source-control/hosted-review.test.ts +++ b/src/main/source-control/hosted-review.test.ts @@ -130,6 +130,46 @@ describe('getHostedReviewForBranch', () => { expect(getPRForBranchMock).toHaveBeenCalledWith('/repo', 'feature', 3, undefined) }) + it('routes local WSL project branch lookup through provider detection and the selected provider', async () => { + getProjectSlugMock.mockResolvedValue(null) + getRepoSlugMock.mockResolvedValue(null) + getBitbucketRepoSlugMock.mockResolvedValue({ workspace: 'team', repoSlug: 'orca' }) + getBitbucketPullRequestForBranchMock.mockResolvedValue({ + number: 22, + title: 'Bitbucket WSL branch', + state: 'open', + url: 'https://bitbucket.org/team/orca/pull-requests/22', + status: 'pending', + updatedAt: '2026-06-16T00:00:00.000Z', + mergeable: 'UNKNOWN' + }) + + await expect( + getHostedReviewForBranch({ + repoPath: '/repo', + branch: 'feature/wsl', + linkedBitbucketPR: 22, + localGitExecOptions: { wslDistro: 'Ubuntu' } + }) + ).resolves.toMatchObject({ + provider: 'bitbucket', + number: 22, + status: 'pending' + }) + + const executionOptions = { localGitExecOptions: { wslDistro: 'Ubuntu' } } + expect(getProjectSlugMock).toHaveBeenCalledWith('/repo', undefined, executionOptions) + expect(getRepoSlugMock).toHaveBeenCalledWith('/repo', undefined, executionOptions) + expect(getBitbucketRepoSlugMock).toHaveBeenCalledWith('/repo', undefined, executionOptions) + expect(getBitbucketPullRequestForBranchMock).toHaveBeenCalledWith( + '/repo', + 'feature/wsl', + 22, + undefined, + executionOptions + ) + }) + it('uses fallback GitHub PR when branch is empty', async () => { getProjectSlugMock.mockResolvedValue(null) getRepoSlugMock.mockResolvedValue({ owner: 'o', repo: 'r' }) diff --git a/src/main/source-control/hosted-review.ts b/src/main/source-control/hosted-review.ts index 37de21baf66..f8e2d3ebdaf 100644 --- a/src/main/source-control/hosted-review.ts +++ b/src/main/source-control/hosted-review.ts @@ -4,6 +4,7 @@ import { getForgeProviderForRepository, type ForgeProviderId } from './forge-provider' +import type { HostedReviewExecutionOptions } from './hosted-review-git-options' function reviewLinkForProvider( input: Parameters[0], @@ -26,17 +27,19 @@ function reviewLinkForProvider( } } -export async function getHostedReviewForBranch(input: { - repoPath: string - connectionId?: string | null - branch: string - linkedGitHubPR?: number | null - fallbackGitHubPR?: number | null - linkedGitLabMR?: number | null - linkedBitbucketPR?: number | null - linkedAzureDevOpsPR?: number | null - linkedGiteaPR?: number | null -}): Promise { +export async function getHostedReviewForBranch( + input: { + repoPath: string + connectionId?: string | null + branch: string + linkedGitHubPR?: number | null + fallbackGitHubPR?: number | null + linkedGitLabMR?: number | null + linkedBitbucketPR?: number | null + linkedAzureDevOpsPR?: number | null + linkedGiteaPR?: number | null + } & HostedReviewExecutionOptions +): Promise { const branchName = input.branch.replace(/^refs\/heads\//, '') // Why: detached HEAD cannot use branch lookup, but provider-specific exact // ids can still resolve the review without probing an empty branch name. @@ -54,7 +57,8 @@ export async function getHostedReviewForBranch(input: { const provider = await getForgeProviderForRepository({ repoPath: input.repoPath, - connectionId: input.connectionId + connectionId: input.connectionId, + ...(input.localGitExecOptions ? { localGitExecOptions: input.localGitExecOptions } : {}) }) if (!provider) { return null @@ -63,6 +67,7 @@ export async function getHostedReviewForBranch(input: { repoPath: input.repoPath, connectionId: input.connectionId, branch: branchName, + ...(input.localGitExecOptions ? { localGitExecOptions: input.localGitExecOptions } : {}), ...reviewLinkForProvider(input, provider.id) }) } diff --git a/src/main/ssh/ssh-config-loader-regression.test.ts b/src/main/ssh/ssh-config-loader-regression.test.ts index 85899df250c..8185d9cd3aa 100644 --- a/src/main/ssh/ssh-config-loader-regression.test.ts +++ b/src/main/ssh/ssh-config-loader-regression.test.ts @@ -14,6 +14,16 @@ function normalizeWin(value: string): string { return win32.normalize(value.replaceAll('/', '\\')) } +function platformSshHome(): string { + return process.platform === 'win32' ? 'C:\\Users\\testuser' : '/home/testuser' +} + +function platformSshPath(home: string, relativePath: string): string { + return process.platform === 'win32' + ? normalizeWin(`${home}/${relativePath}`) + : `${home}/${relativePath}` +} + async function mockOs( home: string, username = 'testuser', @@ -141,10 +151,10 @@ describe('loadUserSshConfig regressions', () => { it('skips non-regular include targets without reading them', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const home = '/home/testuser' - const configPath = `${home}/.ssh/config` - const unsafePath = `${home}/.ssh/unsafe.conf` - const safePath = `${home}/.ssh/safe.conf` + const home = platformSshHome() + const configPath = platformSshPath(home, '.ssh/config') + const unsafePath = platformSshPath(home, '.ssh/unsafe.conf') + const safePath = platformSshPath(home, '.ssh/safe.conf') const unsafeReadSpy = vi.fn() await mockOs(home) @@ -181,10 +191,10 @@ describe('loadUserSshConfig regressions', () => { it('caps overly broad include globs and skips the remainder', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const home = '/home/testuser' - const configPath = `${home}/.ssh/config` + const home = platformSshHome() + const configPath = platformSshPath(home, '.ssh/config') const includePaths = Array.from({ length: 2000 }, (_, index) => { - return `${home}/.ssh/conf.d/${String(index).padStart(4, '0')}.conf` + return platformSshPath(home, `.ssh/conf.d/${String(index).padStart(4, '0')}.conf`) }) const readPaths = new Set() @@ -226,10 +236,10 @@ describe('loadUserSshConfig regressions', () => { it('skips oversized include files without reading them', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const home = '/home/testuser' - const configPath = `${home}/.ssh/config` - const oversizedPath = `${home}/.ssh/oversized.conf` - const safePath = `${home}/.ssh/safe.conf` + const home = platformSshHome() + const configPath = platformSshPath(home, '.ssh/config') + const oversizedPath = platformSshPath(home, '.ssh/oversized.conf') + const safePath = platformSshPath(home, '.ssh/safe.conf') const oversizedReadSpy = vi.fn() await mockOs(home) diff --git a/src/main/ssh/ssh-system-fallback.test.ts b/src/main/ssh/ssh-system-fallback.test.ts index 5a79e07cce0..7e15963e269 100644 --- a/src/main/ssh/ssh-system-fallback.test.ts +++ b/src/main/ssh/ssh-system-fallback.test.ts @@ -33,6 +33,13 @@ import { import { getRemoteHostPlatform } from './ssh-remote-platform' import type { SshTarget } from '../../shared/ssh-types' +const SYSTEM_SSH_PATH = + process.platform === 'win32' ? 'C:\\Windows\\System32\\OpenSSH\\ssh.exe' : '/usr/bin/ssh' + +function mockSystemSshExists(): void { + existsSyncMock.mockImplementation((p: string) => p === SYSTEM_SSH_PATH) +} + function createTarget(overrides?: Partial): SshTarget { return { id: 'target-1', @@ -109,8 +116,8 @@ describe('findSystemSsh', () => { }) it('returns the first existing ssh path', () => { - existsSyncMock.mockImplementation((p: string) => p === '/usr/bin/ssh') - expect(findSystemSsh()).toBe('/usr/bin/ssh') + mockSystemSshExists() + expect(findSystemSsh()).toBe(SYSTEM_SSH_PATH) }) it('returns null when no ssh binary is found', () => { @@ -146,14 +153,14 @@ describe('spawnSystemSsh', () => { kill: vi.fn() } spawnMock.mockReturnValue(mockProc) - existsSyncMock.mockImplementation((p: string) => p === '/usr/bin/ssh') + mockSystemSshExists() }) it('spawns ssh with correct arguments for basic target', () => { spawnSystemSsh(createTarget()) expect(spawnMock).toHaveBeenCalledWith( - '/usr/bin/ssh', + SYSTEM_SSH_PATH, expect.arrayContaining(['-T', 'deploy@example.com']), expect.objectContaining({ stdio: ['pipe', 'pipe', 'pipe'] }) ) @@ -239,7 +246,7 @@ describe('spawnSystemSsh', () => { spawnSystemSshCommand(createTarget({ configHost: 'fdpass-host' }), 'echo hello') expect(spawnMock).toHaveBeenCalledWith( - '/usr/bin/ssh', + SYSTEM_SSH_PATH, expect.arrayContaining(['--', 'deploy@fdpass-host', "exec /bin/sh -c 'echo hello'"]), expect.objectContaining({ stdio: ['pipe', 'pipe', 'pipe'] }) ) @@ -251,7 +258,7 @@ describe('spawnSystemSsh', () => { }) expect(spawnMock).toHaveBeenCalledWith( - '/usr/bin/ssh', + SYSTEM_SSH_PATH, expect.arrayContaining(['--', 'deploy@fdpass-host', 'echo hello']), expect.objectContaining({ stdio: ['pipe', 'pipe', 'pipe'] }) ) @@ -379,7 +386,7 @@ describe('system SSH operation aborts', () => { beforeEach(() => { existsSyncMock.mockReset() spawnMock.mockReset() - existsSyncMock.mockImplementation((p: string) => p === '/usr/bin/ssh') + mockSystemSshExists() }) it('rejects directory uploads when aborted even if child processes do not close', async () => { diff --git a/src/main/startup/run-electron-vite-dev-web.test.ts b/src/main/startup/run-electron-vite-dev-web.test.ts index 7bee7d6acc5..ada8bb3c9fa 100644 --- a/src/main/startup/run-electron-vite-dev-web.test.ts +++ b/src/main/startup/run-electron-vite-dev-web.test.ts @@ -1,6 +1,6 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join, resolve } from 'node:path' +import { dirname, join, resolve } from 'node:path' import { spawn, type ChildProcess } from 'node:child_process' import { afterEach, describe, expect, it } from 'vitest' @@ -96,7 +96,9 @@ function stashWebBuild(): () => void { } } - const tempDir = mkdtempSync(join(tmpdir(), 'orca-dev-web-stash-')) + // Why: Windows temp can be on a different drive from the workspace, and + // renameSync cannot move directories across devices. + const tempDir = mkdtempSync(join(dirname(outWebPath), '.orca-dev-web-stash-')) const stashedPath = join(tempDir, 'web') renameSync(outWebPath, stashedPath) return () => { diff --git a/src/main/terminal-history.test.ts b/src/main/terminal-history.test.ts index a300f02181c..c5e8e2ca805 100644 --- a/src/main/terminal-history.test.ts +++ b/src/main/terminal-history.test.ts @@ -137,7 +137,7 @@ describe('terminal-history', () => { it('creates directory with mode 0o700', () => { ensureHistoryDir('abcdef0123456789') expect(mkdirSyncMock).toHaveBeenCalledWith( - '/fake/userData/terminal-history/abcdef0123456789', + expect.stringMatching(/[\\/]fake[\\/]userData[\\/]terminal-history[\\/]abcdef0123456789$/), { recursive: true, mode: 0o700 } ) }) @@ -407,12 +407,40 @@ describe('terminal-history', () => { ) expect(mkdirSyncMock).toHaveBeenCalledWith( - expect.stringContaining('terminal-history-wsl/Ubuntu'), + expect.stringMatching(/[\\/]terminal-history-wsl[\\/]Ubuntu[\\/]/), expect.any(Object) ) } finally { Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) } }) + + it('uses the project WSL distro hint when cwd is a Windows path', () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + + try { + parseWslPathMock.mockReturnValue(null) + toLinuxPathMock.mockImplementation((p: string) => p.replace(/^C:\\/i, '/mnt/c/')) + mkdirSyncMock.mockReturnValue(undefined) + existsSyncMock.mockReturnValue(true) + getPathMock.mockReturnValue('C:\\Users\\alice\\AppData\\Roaming\\Orca') + + const env: Record = {} + const result = injectHistoryEnv(env, 'repo-1::C:\\repo', '/bin/bash', 'C:\\repo', { + wslDistro: 'Ubuntu' + }) + + expect(mkdirSyncMock).toHaveBeenCalledWith( + expect.stringMatching(/[\\/]terminal-history-wsl[\\/]Ubuntu[\\/]/), + expect.any(Object) + ) + expect(toLinuxPathMock).toHaveBeenCalled() + expect(result.histFile).toMatch(/^\/mnt\/c\//) + expect(env.HISTFILE).toBe(result.histFile) + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) }) }) diff --git a/src/main/terminal-history.ts b/src/main/terminal-history.ts index 32df3cee92c..a2a8f929f96 100644 --- a/src/main/terminal-history.ts +++ b/src/main/terminal-history.ts @@ -132,7 +132,8 @@ export function injectHistoryEnv( spawnEnv: Record, worktreeId: string, shellPath: string, - cwd: string + cwd: string, + options: { wslDistro?: string | null } = {} ): HistoryInjectionResult { const shell = resolveShellKind(shellPath) const result: HistoryInjectionResult = { shell, histFile: null } @@ -154,7 +155,8 @@ export function injectHistoryEnv( // WSL: store under a separate root keyed by distro, and convert the // HISTFILE path to a Linux-visible /mnt/... path for the inner shell. const wslInfo = process.platform === 'win32' ? parseWslPath(cwd) : null - const histDir = ensureHistoryDir(worktreeHash, wslInfo?.distro) + const wslDistro = wslInfo?.distro ?? options.wslDistro?.trim() + const histDir = ensureHistoryDir(worktreeHash, wslDistro) if (!histDir) { // Directory creation failed — degrade gracefully to shared history. return result @@ -165,7 +167,7 @@ export function injectHistoryEnv( const histFilePath = join(histDir, filename) // For WSL, convert the Windows path to a Linux-visible path. - spawnEnv.HISTFILE = wslInfo ? toLinuxPath(histFilePath) : histFilePath + spawnEnv.HISTFILE = wslDistro ? toLinuxPath(histFilePath) : histFilePath result.histFile = spawnEnv.HISTFILE return result diff --git a/src/main/text-generation/commit-message-agent-environment.test.ts b/src/main/text-generation/commit-message-agent-environment.test.ts index b7b91335132..b4b24059f1b 100644 --- a/src/main/text-generation/commit-message-agent-environment.test.ts +++ b/src/main/text-generation/commit-message-agent-environment.test.ts @@ -134,4 +134,35 @@ describe('prepareLocalCommitMessageAgentEnv', () => { expect(result).toEqual({ ok: true }) }) + + it('passes WSL managed Codex homes as Linux paths for WSL-local commit generation', async () => { + const result = await prepareLocalCommitMessageAgentEnv( + 'codex', + { + prepareForCodexLaunch: (target) => { + expect(target).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + return '\\\\wsl.localhost\\Ubuntu\\home\\tester\\.codex' + } + }, + { runtime: 'wsl', wslDistro: 'Ubuntu' } + ) + + expect(result).toEqual({ + ok: true, + env: expect.objectContaining({ + CODEX_HOME: '/home/tester/.codex' + }) + }) + }) + + it('does not hydrate host shell config roots for WSL-local commit generation', async () => { + process.env.OPENCODE_CONFIG_DIR = 'C:\\Users\\tester\\opencode' + + const result = await prepareLocalCommitMessageAgentEnv('opencode', undefined, { + runtime: 'wsl', + wslDistro: 'Ubuntu' + }) + + expect(result).toEqual({ ok: true }) + }) }) diff --git a/src/main/text-generation/commit-message-agent-environment.ts b/src/main/text-generation/commit-message-agent-environment.ts index 25dbdd8ccc1..09be28ce3fe 100644 --- a/src/main/text-generation/commit-message-agent-environment.ts +++ b/src/main/text-generation/commit-message-agent-environment.ts @@ -4,8 +4,15 @@ import { readShellStartupEnvVar } from '../pty/shell-startup-env' import { parseWslUncPath } from '../../shared/wsl-paths' export type CommitMessageAgentEnvironmentResolvers = { - prepareForCodexLaunch?: () => string | null - prepareForClaudeLaunch?: () => Promise + prepareForCodexLaunch?: (target?: CommitMessageAgentRuntimeTarget) => string | null + prepareForClaudeLaunch?: ( + target?: CommitMessageAgentRuntimeTarget + ) => Promise +} + +export type CommitMessageAgentRuntimeTarget = { + runtime?: 'host' | 'wsl' + wslDistro?: string | null } function cloneProcessEnv(): Record { @@ -62,9 +69,10 @@ function prepareShellConfigDirEnv(agentId: string): { ok: true; env?: NodeJS.Pro export async function prepareLocalCommitMessageAgentEnv( agentId: string, - resolvers: CommitMessageAgentEnvironmentResolvers | undefined + resolvers: CommitMessageAgentEnvironmentResolvers | undefined, + target?: CommitMessageAgentRuntimeTarget ): Promise<{ ok: true; env?: NodeJS.ProcessEnv } | { ok: false; error: string }> { - const shellConfigEnv = prepareShellConfigDirEnv(agentId) + const shellConfigEnv = target?.runtime === 'wsl' ? null : prepareShellConfigDirEnv(agentId) if (shellConfigEnv) { return shellConfigEnv } @@ -74,8 +82,18 @@ export async function prepareLocalCommitMessageAgentEnv( try { if (agentId === 'codex' && resolvers.prepareForCodexLaunch) { - const codexHomePath = resolvers.prepareForCodexLaunch() - if (codexHomePath && parseWslUncPath(codexHomePath)) { + const codexHomePath = resolvers.prepareForCodexLaunch(target) + const wslCodexHome = codexHomePath ? parseWslUncPath(codexHomePath) : null + if (target?.runtime === 'wsl') { + const codexHomeForTarget = wslCodexHome?.linuxPath ?? null + return { + ok: true, + env: codexHomeForTarget + ? { ...cloneProcessEnv(), CODEX_HOME: codexHomeForTarget } + : undefined + } + } + if (codexHomePath && wslCodexHome) { // Why: this local generation path spawns the host Codex binary. A WSL // managed home is only valid when the process is routed through wsl.exe. return { ok: true } @@ -87,7 +105,7 @@ export async function prepareLocalCommitMessageAgentEnv( } if (agentId === 'claude' && resolvers.prepareForClaudeLaunch) { - const preparation = await resolvers.prepareForClaudeLaunch() + const preparation = await resolvers.prepareForClaudeLaunch(target) const env = applyClaudeEnvPatch(cloneProcessEnv(), preparation.envPatch, { stripAuthEnv: preparation.stripAuthEnv }) diff --git a/src/main/text-generation/commit-message-text-generation.test.ts b/src/main/text-generation/commit-message-text-generation.test.ts index d640cc160e0..676b81e1467 100644 --- a/src/main/text-generation/commit-message-text-generation.test.ts +++ b/src/main/text-generation/commit-message-text-generation.test.ts @@ -1,7 +1,7 @@ /* eslint-disable max-lines -- Why: local/remote generation, cancellation, and env propagation share subprocess mocks; splitting would obscure the cross-path invariants these tests protect. */ -import { spawn } from 'child_process' +import { exec, spawn } from 'child_process' import type * as ChildProcess from 'child_process' import { EventEmitter } from 'events' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -24,10 +24,17 @@ vi.mock('child_process', async (importOriginal) => { const actual = await importOriginal() return { ...actual, + exec: vi.fn((_command, callback) => { + if (typeof callback === 'function') { + callback(null, '', '') + } + return new actual.ChildProcess() + }), spawn: vi.fn(actual.spawn) } }) +const execMock = vi.mocked(exec) const spawnMock = vi.mocked(spawn) type MockDiscoveryChild = EventEmitter & { @@ -62,7 +69,17 @@ function withPlatform(platform: NodeJS.Platform, fn: () => T): T { } } +function expectChildTerminated(child: { pid: number; kill: ReturnType }): void { + if (process.platform === 'win32') { + expect(execMock).toHaveBeenCalledWith(`taskkill /pid ${child.pid} /T /F`, expect.any(Function)) + expect(child.kill).not.toHaveBeenCalled() + return + } + expect(child.kill).toHaveBeenCalledWith('SIGKILL') +} + beforeEach(() => { + execMock.mockClear() spawnMock.mockClear() }) @@ -350,11 +367,60 @@ describe('discoverCommitMessageModelsLocal', () => { success: true, defaultModelId: 'auto' }) - expect(spawnMock).toHaveBeenCalledWith( - 'npx', - ['cursor-agent', '--list-models'], - expect.objectContaining({ windowsHide: true }) - ) + if (process.platform === 'win32') { + expect(spawnMock).toHaveBeenCalledWith( + expect.stringMatching(/cmd\.exe$/i), + ['/d', '/c', expect.stringMatching(/npx\.cmd$/i), 'cursor-agent', '--list-models'], + expect.objectContaining({ windowsHide: true }) + ) + } else { + expect(spawnMock).toHaveBeenCalledWith( + 'npx', + ['cursor-agent', '--list-models'], + expect.objectContaining({ windowsHide: true }) + ) + } + }) + + it('discovers dynamic models through the selected WSL distro login shell', async () => { + await withPlatform('win32', async () => { + const listeners = new Map void>() + const child = { + pid: 123, + kill: vi.fn(), + stdout: { on: vi.fn((event, callback) => listeners.set(`stdout:${event}`, callback)) }, + stderr: { on: vi.fn((event, callback) => listeners.set(`stderr:${event}`, callback)) }, + stdin: { end: vi.fn() }, + on: vi.fn((event, callback) => listeners.set(event, callback)) + } + spawnMock.mockReturnValue(child as never) + + const pending = discoverCommitMessageModelsLocal('cursor', undefined, undefined, { + cwd: 'C:\\repo', + wslDistro: 'Ubuntu' + }) + + listeners.get('stdout:data')?.(Buffer.from('auto - Auto\n')) + listeners.get('close')?.(0) + + await expect(pending).resolves.toMatchObject({ + success: true, + defaultModelId: 'auto' + }) + expect(spawnMock).toHaveBeenCalledWith( + 'wsl.exe', + ['-d', 'Ubuntu', '--', 'sh', '-lc', expect.any(String)], + expect.objectContaining({ + cwd: undefined, + windowsHide: true + }) + ) + const shellCommand = spawnMock.mock.calls[0]?.[1]?.[5] as string + expect(shellCommand).toContain('getent passwd') + expect(shellCommand).toContain('/mnt/c/repo') + expect(shellCommand).toContain("'cursor-agent'") + expect(shellCommand).toContain('--list-models') + }) }) it('falls back to static models when dynamic discovery returns no parseable models', async () => { @@ -428,7 +494,7 @@ describe('discoverCommitMessageModelsLocal', () => { await vi.advanceTimersByTimeAsync(60_000) await assertion - expect(child.kill).toHaveBeenCalledWith('SIGKILL') + expectChildTerminated(child) expect(child.stdout.listenerCount('data')).toBe(0) expect(child.stderr.listenerCount('data')).toBe(0) expect(child.listenerCount('error')).toBe(0) @@ -450,7 +516,7 @@ describe('discoverCommitMessageModelsLocal', () => { success: false, error: 'Cursor returned too much model data.' }) - expect(child.kill).toHaveBeenCalledWith('SIGKILL') + expectChildTerminated(child) expect(child.stdout.listenerCount('data')).toBe(0) expect(child.stderr.listenerCount('data')).toBe(0) expect(child.listenerCount('error')).toBe(0) @@ -807,7 +873,7 @@ describe('generateCommitMessageFromContext', () => { error: 'agent CLI command produced too much output. Check the agent CLI configuration and try again.' }) - expect(child.kill).toHaveBeenCalledWith('SIGKILL') + expectChildTerminated(child) }) it('passes prepared provider environment to local agent subprocesses', async () => { @@ -856,8 +922,70 @@ describe('generateCommitMessageFromContext', () => { ) }) + it('routes WSL local commit generation through the selected distro login shell', async () => { + await withPlatform('win32', async () => { + process.env.ORCA_HOST_ONLY_SECRET = 'do-not-leak' + const listeners = new Map void>() + const child = { + pid: 123, + kill: vi.fn(), + stdout: { on: vi.fn((event, callback) => listeners.set(`stdout:${event}`, callback)) }, + stderr: { on: vi.fn((event, callback) => listeners.set(`stderr:${event}`, callback)) }, + stdin: { end: vi.fn() }, + on: vi.fn((event, callback) => listeners.set(event, callback)) + } + spawnMock.mockReturnValue(child as never) + + const pending = generateCommitMessageFromContext( + { + branch: 'main', + stagedSummary: 'M\tREADME.md', + stagedPatch: '+hello' + }, + { + agentId: 'custom', + model: '', + customAgentCommand: 'agent --mode fast' + }, + { + kind: 'local', + cwd: 'C:\\repo', + wslDistro: 'Ubuntu 24.04', + env: { ...process.env, CODEX_HOME: '/home/tester/.codex' } + } + ) + + listeners.get('stdout:data')?.(Buffer.from('Update README\n')) + listeners.get('close')?.(0) + + await expect(pending).resolves.toMatchObject({ + success: true, + message: 'Update README' + }) + expect(spawnMock).toHaveBeenCalledWith( + 'wsl.exe', + ['-d', 'Ubuntu 24.04', '--', 'sh', '-lc', expect.any(String)], + expect.objectContaining({ + cwd: undefined, + windowsHide: true, + env: expect.objectContaining({ CODEX_HOME: '/home/tester/.codex' }) + }) + ) + const spawnEnv = spawnMock.mock.calls[0]?.[2]?.env as NodeJS.ProcessEnv + expect(spawnEnv.ORCA_HOST_ONLY_SECRET).toBeUndefined() + const shellCommand = spawnMock.mock.calls[0]?.[1]?.[5] as string + expect(shellCommand).toContain('getent passwd') + expect(shellCommand).toContain('exec "$_orca_wsl_shell" -ilc') + expect(shellCommand).toContain('/mnt/c/repo') + expect(shellCommand).toContain("'agent'") + expect(shellCommand).toContain('--mode') + expect(shellCommand).toContain('fast') + }) + }) + it('keeps local commit-message and pull-request cancellation lanes separate', async () => { const children: { + pid: number kill: ReturnType listeners: Map void> }[] = [] @@ -871,7 +999,7 @@ describe('generateCommitMessageFromContext', () => { stdin: { end: vi.fn() }, on: vi.fn((event, callback) => listeners.set(event, callback)) } - children.push({ kill: child.kill, listeners }) + children.push({ pid: child.pid, kill: child.kill, listeners }) return child as never }) @@ -916,7 +1044,7 @@ describe('generateCommitMessageFromContext', () => { cancelGenerateCommitMessageLocal('/repo') - expect(children[0]?.kill).toHaveBeenCalledWith('SIGKILL') + expectChildTerminated(children[0]!) expect(children[1]?.kill).not.toHaveBeenCalled() children[0]?.listeners.get('close')?.(null) @@ -947,6 +1075,7 @@ describe('generateCommitMessageFromContext', () => { it('keeps local pull-request cancellation from stopping commit-message generation', async () => { const children: { + pid: number kill: ReturnType listeners: Map void> }[] = [] @@ -960,7 +1089,7 @@ describe('generateCommitMessageFromContext', () => { stdin: { end: vi.fn() }, on: vi.fn((event, callback) => listeners.set(event, callback)) } - children.push({ kill: child.kill, listeners }) + children.push({ pid: child.pid, kill: child.kill, listeners }) return child as never }) @@ -1006,7 +1135,7 @@ describe('generateCommitMessageFromContext', () => { cancelGeneratePullRequestFieldsLocal('/repo') expect(children[0]?.kill).not.toHaveBeenCalled() - expect(children[1]?.kill).toHaveBeenCalledWith('SIGKILL') + expectChildTerminated(children[1]!) const commitStdout = children[0]?.listeners.get('stdout:data') commitStdout?.(Buffer.from('Update README\n')) @@ -1151,7 +1280,7 @@ describe('generateCommitMessageFromContext', () => { cancelGeneratePullRequestFieldsLocal('/repo') listeners.get('close')?.(null) - expect(child.kill).toHaveBeenCalledWith('SIGKILL') + expectChildTerminated(child) await expect(pullRequest).resolves.toEqual({ success: false, error: 'Generation canceled.', @@ -1207,7 +1336,7 @@ describe('generateCommitMessageFromContext', () => { ) cancelGenerateCommitMessageLocal('/repo') - expect(child.kill).toHaveBeenCalledWith('SIGKILL') + expectChildTerminated(child) await Promise.resolve() await Promise.resolve() await Promise.resolve() diff --git a/src/main/text-generation/commit-message-text-generation.ts b/src/main/text-generation/commit-message-text-generation.ts index bfa1ff6cc7e..ba035bddf11 100644 --- a/src/main/text-generation/commit-message-text-generation.ts +++ b/src/main/text-generation/commit-message-text-generation.ts @@ -48,6 +48,7 @@ import { WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR } from '../win32-utils' import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic' +import { wslAwareSpawn } from '../git/runner' const GENERATION_TIMEOUT_MS = 60_000 const MAX_AGENT_OUTPUT_BYTES = 4 * 1024 * 1024 @@ -88,7 +89,7 @@ export type RemoteCommitMessageExecResult = { export type TextGenerationOperation = 'commit-message' | 'pull-request-fields' | 'branch-name' export type CommitMessageGenerationTarget = - | { kind: 'local'; cwd: string; env?: NodeJS.ProcessEnv } + | { kind: 'local'; cwd: string; env?: NodeJS.ProcessEnv; wslDistro?: string } | { kind: 'remote' cwd: string @@ -109,6 +110,11 @@ type InternalTextGenerationResult = | { success: true; rawOutput: string; agentLabel?: string } | { success: false; error: string; canceled?: boolean } +export type CommitMessageModelDiscoveryLocalOptions = { + cwd?: string + wslDistro?: string +} + export function trimGeneratedCommitMessage(message: string): string { return message.replace(/\s+$/, '') } @@ -264,7 +270,8 @@ function planModelDiscovery( export async function discoverCommitMessageModelsLocal( agentId: TuiAgent, env: NodeJS.ProcessEnv | undefined, - agentCommandOverride?: string + agentCommandOverride?: string, + options: CommitMessageModelDiscoveryLocalOptions = {} ): Promise { const spec = getCommitMessageAgentSpec(agentId) if (!spec) { @@ -284,18 +291,29 @@ export async function discoverCommitMessageModelsLocal( resolve({ success: false, error: planned.error }) return } - const resolvedBinary = - process.platform === 'win32' - ? resolveCliCommand(planned.plan.binary, { - pathEnv: spawnEnv.PATH ?? spawnEnv.Path ?? null - }) - : planned.plan.binary - const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(resolvedBinary, planned.plan.args) - child = spawn(spawnCmd, spawnArgs, { - env: spawnEnv, - stdio: ['ignore', 'pipe', 'pipe'], - windowsHide: true - }) + if (process.platform === 'win32' && options.wslDistro) { + child = wslAwareSpawn(planned.plan.binary, planned.plan.args, { + cwd: options.cwd, + env: buildWslLauncherEnv(env), + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + wslDistro: options.wslDistro, + useWslLoginShell: true + }) + } else { + const resolvedBinary = + process.platform === 'win32' + ? resolveCliCommand(planned.plan.binary, { + pathEnv: spawnEnv.PATH ?? spawnEnv.Path ?? null + }) + : planned.plan.binary + const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(resolvedBinary, planned.plan.args) + child = spawn(spawnCmd, spawnArgs, { + env: spawnEnv, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true + }) + } } catch (error) { console.error('[commit-message] Failed to spawn model discovery:', error) resolve({ @@ -467,6 +485,17 @@ function killProcessTree(child: ChildProcess): void { // Keying by operation plus `local:${cwd}` keeps local cancellation independent // from SSH worktrees and from other generation features in the same worktree. const cancelTokensByLane = new Map void>() +const WSL_LAUNCHER_ENV_KEYS = [ + 'ComSpec', + 'COMSPEC', + 'Path', + 'PATH', + 'PATHEXT', + 'SystemRoot', + 'TEMP', + 'TMP', + 'WINDIR' +] as const function localLaneKey(operation: TextGenerationOperation, cwd: string): string { return `${operation}:local:${cwd}` @@ -476,29 +505,57 @@ export function cancelGenerateCommitMessageLocal(cwd: string): void { cancelTokensByLane.get(localLaneKey('commit-message', cwd))?.() } +function buildWslLauncherEnv(explicitEnv: NodeJS.ProcessEnv | undefined): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = {} + for (const key of WSL_LAUNCHER_ENV_KEYS) { + const value = process.env[key] + if (value !== undefined) { + env[key] = value + } + } + for (const [key, value] of Object.entries(explicitEnv ?? {})) { + if (value !== undefined && value !== process.env[key]) { + env[key] = value + } + } + return env +} + async function runLocalPlan( plan: CommitMessagePlan, cwd: string, env: NodeJS.ProcessEnv | undefined, emptyResultName = 'message', - operation: TextGenerationOperation = 'commit-message' + operation: TextGenerationOperation = 'commit-message', + wslDistro?: string ): Promise { const { binary, args, stdinPayload, label } = plan return new Promise((resolve) => { let child: ChildProcess try { const spawnEnv = env ?? process.env - const resolvedBinary = - process.platform === 'win32' - ? resolveCliCommand(binary, { pathEnv: spawnEnv.PATH ?? spawnEnv.Path ?? null }) - : binary - const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(resolvedBinary, args) - child = spawn(spawnCmd, spawnArgs, { - cwd, - env: spawnEnv, - stdio: ['pipe', 'pipe', 'pipe'], - windowsHide: true - }) + if (process.platform === 'win32' && wslDistro) { + child = wslAwareSpawn(binary, args, { + cwd, + env: buildWslLauncherEnv(env), + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + wslDistro, + useWslLoginShell: true + }) + } else { + const resolvedBinary = + process.platform === 'win32' + ? resolveCliCommand(binary, { pathEnv: spawnEnv.PATH ?? spawnEnv.Path ?? null }) + : binary + const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(resolvedBinary, args) + child = spawn(spawnCmd, spawnArgs, { + cwd, + env: spawnEnv, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true + }) + } } catch (error) { if (error instanceof UnsafeWindowsBatchArgumentsError) { resolve({ @@ -776,7 +833,14 @@ export async function generateCommitMessageFromContext( const internalResult = target.kind === 'remote' ? await runRemotePlan(planned.plan, target) - : await runLocalPlan(planned.plan, target.cwd, target.env) + : await runLocalPlan( + planned.plan, + target.cwd, + target.env, + 'message', + 'commit-message', + target.wslDistro + ) return formatCommitMessageGenerationResult(internalResult) } @@ -841,7 +905,14 @@ export async function generatePullRequestFieldsFromContext( const internalResult = target.kind === 'remote' ? await runRemotePlan(planned.plan, target, 'details', 'pull-request-fields') - : await runLocalPlan(planned.plan, target.cwd, target.env, 'details', 'pull-request-fields') + : await runLocalPlan( + planned.plan, + target.cwd, + target.env, + 'details', + 'pull-request-fields', + target.wslDistro + ) return formatPullRequestFieldsGenerationResult(internalResult, context) } @@ -876,7 +947,14 @@ export async function generateBranchNameFromContext( const internalResult = target.kind === 'remote' ? await runRemotePlan(planned.plan, target, 'branch name', 'branch-name') - : await runLocalPlan(planned.plan, target.cwd, target.env, 'branch name', 'branch-name') + : await runLocalPlan( + planned.plan, + target.cwd, + target.env, + 'branch name', + 'branch-name', + target.wslDistro + ) if (!internalResult.success) { return internalResult } diff --git a/src/main/warp-themes/discovery.ts b/src/main/warp-themes/discovery.ts index 67552fbeb8d..af3588756f6 100644 --- a/src/main/warp-themes/discovery.ts +++ b/src/main/warp-themes/discovery.ts @@ -29,9 +29,10 @@ function readDirectoryEntries(directoryPath: string): Dirent[] { function addDedupeDirectory( directories: string[], seenDirectories: Set, - directoryPath: string + directoryPath: string, + pathImpl: typeof path.posix ): void { - const normalizedPath = path.normalize(path.resolve(directoryPath)) + const normalizedPath = pathImpl.normalize(pathImpl.resolve(directoryPath)) if (seenDirectories.has(normalizedPath)) { return } @@ -39,39 +40,53 @@ function addDedupeDirectory( directories.push(directoryPath) } -function warpThemeDirectoriesFromDataHomes(dataHomes: string[]): string[] { +function warpThemeDirectoriesFromDataHomes( + dataHomes: string[], + pathImpl: typeof path.posix +): string[] { const directories: string[] = [] const seenDirectories = new Set() for (const dataHome of dataHomes) { - addDedupeDirectory(directories, seenDirectories, path.join(dataHome, 'themes')) + addDedupeDirectory(directories, seenDirectories, pathImpl.join(dataHome, 'themes'), pathImpl) } return directories } function getMacWarpThemeDirectories(home: string): string[] { - return warpThemeDirectoriesFromDataHomes([ - ...WARP_CHANNELS.map((channel) => path.join(home, channel.macName)), - ...readDirectoryEntries(home) - .filter((entry) => entry.isDirectory() && entry.name.startsWith('.warp')) - .map((entry) => path.join(home, entry.name)) - ]) + const pathImpl = path.posix + return warpThemeDirectoriesFromDataHomes( + [ + ...WARP_CHANNELS.map((channel) => pathImpl.join(home, channel.macName)), + ...readDirectoryEntries(home) + .filter((entry) => entry.isDirectory() && entry.name.startsWith('.warp')) + .map((entry) => pathImpl.join(home, entry.name)) + ], + pathImpl + ) } function getLinuxWarpThemeDirectories(home: string): string[] { + const pathImpl = path.posix const xdgDataHome = process.env.XDG_DATA_HOME // Why: XDG_DATA_HOME is only valid as an absolute path; relative values would // make discovery depend on Orca's launch directory. const dataHome = - xdgDataHome && path.isAbsolute(xdgDataHome) ? xdgDataHome : path.join(home, '.local', 'share') - return warpThemeDirectoriesFromDataHomes([ - ...WARP_CHANNELS.map((channel) => path.join(dataHome, channel.linuxName)), - ...readDirectoryEntries(dataHome) - .filter( - (entry) => - entry.isDirectory() && (entry.name === 'warp-terminal' || entry.name.startsWith('warp-')) - ) - .map((entry) => path.join(dataHome, entry.name)) - ]) + xdgDataHome && pathImpl.isAbsolute(xdgDataHome) + ? xdgDataHome + : pathImpl.join(home, '.local', 'share') + return warpThemeDirectoriesFromDataHomes( + [ + ...WARP_CHANNELS.map((channel) => pathImpl.join(dataHome, channel.linuxName)), + ...readDirectoryEntries(dataHome) + .filter( + (entry) => + entry.isDirectory() && + (entry.name === 'warp-terminal' || entry.name.startsWith('warp-')) + ) + .map((entry) => pathImpl.join(dataHome, entry.name)) + ], + pathImpl + ) } function getWindowsWarpThemeDirectories(home: string): string[] { @@ -83,7 +98,8 @@ function getWindowsWarpThemeDirectories(home: string): string[] { addDedupeDirectory( directories, seenDirectories, - path.win32.join(warpAppData, channel.windowsName, 'data', 'themes') + path.win32.join(warpAppData, channel.windowsName, 'data', 'themes'), + path.win32 ) } for (const entry of readDirectoryEntries(warpAppData)) { @@ -93,7 +109,8 @@ function getWindowsWarpThemeDirectories(home: string): string[] { addDedupeDirectory( directories, seenDirectories, - path.win32.join(warpAppData, entry.name, 'data', 'themes') + path.win32.join(warpAppData, entry.name, 'data', 'themes'), + path.win32 ) } return directories diff --git a/src/main/wsl.ts b/src/main/wsl.ts index 3d288350917..70a8ccaad2c 100644 --- a/src/main/wsl.ts +++ b/src/main/wsl.ts @@ -115,6 +115,14 @@ export function listWslDistros(): string[] { } } +export function hasCachedWslDistros(): boolean { + return wslDistroCache !== null +} + +export function getCachedWslDistros(): string[] | null { + return wslDistroCache +} + export function getDefaultWslDistro(): string | null { return listWslDistros()[0] ?? null } @@ -180,3 +188,25 @@ export function isWslAvailable(): boolean { return wslAvailableCache } + +export function hasCachedWslAvailability(): boolean { + return wslAvailableCache !== null +} + +export function getCachedWslAvailability(): boolean | null { + return wslAvailableCache +} + +export function _resetWslCachesForTests(): void { + wslHomeCache.clear() + wslDistroCache = null + wslAvailableCache = null +} + +export function _setWslCachesForTests(args: { + available?: boolean | null + distros?: string[] | null +}): void { + wslAvailableCache = args.available ?? null + wslDistroCache = args.distros ?? null +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 5d885c16c84..bc5566ca3d3 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -12,6 +12,7 @@ import type { NativeFileDropPayload } from '../shared/native-file-drop' import type { AppIdentity } from '../shared/app-identity' import type { TerminalPaneSplitSource } from '../shared/feature-education-telemetry' import type { TaskSourceContext } from '../shared/task-source-context' +import type { ProjectExecutionRuntimeResolution } from '../shared/project-execution-runtime' import type { FolderWorkspacePathStatus, FolderWorkspacePathStatusRequest @@ -129,6 +130,7 @@ import type { PRInfo, PRRefreshOutcome, Project, + ProjectUpdateArgs, Repo, ProjectGroup, ProjectHostSetup, @@ -533,17 +535,16 @@ export type RefreshAgentsResult = { pathFailureReason: ShellHydrationFailureReason } +export type PreflightRuntimeContext = { + wslDistro?: string | null + wslDefault?: boolean + projectRuntime?: ProjectExecutionRuntimeResolution +} + export type PreflightApi = { - check: (args?: { - force?: boolean - wslDistro?: string | null - wslDefault?: boolean - }) => Promise - detectAgents: (args?: { wslDistro?: string | null; wslDefault?: boolean }) => Promise - refreshAgents: (args?: { - wslDistro?: string | null - wslDefault?: boolean - }) => Promise + check: (args?: PreflightRuntimeContext & { force?: boolean }) => Promise + detectAgents: (args?: PreflightRuntimeContext) => Promise + refreshAgents: (args?: PreflightRuntimeContext) => Promise detectRemoteAgents: (args: { connectionId: string }) => Promise } @@ -832,6 +833,7 @@ export type PreloadApi = { } projects: { list: () => Promise + update: (args: ProjectUpdateArgs) => Promise listHostSetups: () => Promise createHostSetup: (args: ProjectHostSetupCreateArgs) => Promise setupExistingFolder: ( @@ -1019,6 +1021,7 @@ export type PreloadApi = { // Preserved from the deleted index.d.ts PtyApi duplicate during the // single-source-of-truth collapse (see docs/preload-typecheck-hole.md §1). shellOverride?: string + projectRuntime?: ProjectExecutionRuntimeResolution // Why: closes the SIGKILL race documented in INVESTIGATION.md — main // sync-flushes the (worktreeId, tabId, leafId → ptyId) binding before // pty:spawn returns. Only the renderer's daemon-host path threads these. diff --git a/src/preload/index.ts b/src/preload/index.ts index bf5c63ce853..d975ec52853 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -9,6 +9,7 @@ import type { AppIdentity } from '../shared/app-identity' import type { CliInstallStatus } from '../shared/cli-install-types' import type { AgentHookInstallStatus } from '../shared/agent-hook-types' import type { TerminalPaneSplitSource } from '../shared/feature-education-telemetry' +import type { ProjectExecutionRuntimeResolution } from '../shared/project-execution-runtime' import type { BaseRefSearchResult, BaseRefDefaultResult, @@ -128,7 +129,7 @@ import type { SpeechTranscriptEvent } from '../shared/speech-types' import type { TelemetryConsentState } from '../shared/telemetry-consent-types' -import type { RefreshAgentsResult } from './api-types' +import type { PreflightRuntimeContext, RefreshAgentsResult } from './api-types' import type { AgentKind, LaunchSource, RequestKind } from '../shared/telemetry-events' import type { AppStarSource } from '../shared/gh-star-source' import type { @@ -529,6 +530,7 @@ const api = { projects: { list: () => ipcRenderer.invoke('projects:list'), + update: (args) => ipcRenderer.invoke('projects:update', args), listHostSetups: () => ipcRenderer.invoke('projectHostSetups:list'), createHostSetup: (args) => ipcRenderer.invoke('projectHostSetups:create', args), setupExistingFolder: (args) => @@ -696,6 +698,7 @@ const api = { worktreeId?: string sessionId?: string shellOverride?: string + projectRuntime?: ProjectExecutionRuntimeResolution // Why: closes the SIGKILL race documented in INVESTIGATION.md by // letting main patch + sync-flush the (worktreeId, tabId, leafId → // ptyId) binding before pty:spawn returns. Only the renderer's @@ -1715,12 +1718,10 @@ const api = { } linear: { connected: boolean } }> => ipcRenderer.invoke('preflight:check', args), - detectAgents: (args?: { wslDistro?: string | null; wslDefault?: boolean }): Promise => + detectAgents: (args?: PreflightRuntimeContext): Promise => ipcRenderer.invoke('preflight:detectAgents', args), - refreshAgents: (args?: { - wslDistro?: string | null - wslDefault?: boolean - }): Promise => ipcRenderer.invoke('preflight:refreshAgents', args), + refreshAgents: (args?: PreflightRuntimeContext): Promise => + ipcRenderer.invoke('preflight:refreshAgents', args), detectRemoteAgents: (args: { connectionId: string }): Promise => ipcRenderer.invoke('preflight:detectRemoteAgents', args) }, diff --git a/src/relay/fs-handler.test.ts b/src/relay/fs-handler.test.ts index 2670569cd6c..b6fe2d8d4cc 100644 --- a/src/relay/fs-handler.test.ts +++ b/src/relay/fs-handler.test.ts @@ -433,8 +433,7 @@ describe('FsHandler', () => { const result = (await dispatcher.callRequest('fs.realpath', { filePath: linkPath })) as string // On macOS, /var is a symlink to /private/var, so resolve both to compare - const { realpathSync } = await import('fs') - expect(result).toBe(realpathSync(realFile)) + expect(result).toBe(await fs.realpath(realFile)) }) it('does not let stale pending watch remove newer replacement watch', async () => { diff --git a/src/relay/git-handler-worktree-ops.ts b/src/relay/git-handler-worktree-ops.ts index e47fda70486..c4604da3c12 100644 --- a/src/relay/git-handler-worktree-ops.ts +++ b/src/relay/git-handler-worktree-ops.ts @@ -1,32 +1,8 @@ import * as path from 'path' -import type { RemoveWorktreeResult } from '../shared/types' import { resolveWorktreeAddBaseRef } from '../shared/worktree-base-ref' -import { deleteAlreadyMergedRelayBranchAfterSafeDeleteFailure } from './git-handler-branch-cleanup' import type { GitExec } from './git-handler-ops' import { isUnsupportedWorktreeListZError, parseWorktreeList } from './git-handler-utils' - -function getErrorText(error: unknown): string { - if (typeof error === 'object' && error !== null) { - const parts: string[] = [] - if ('message' in error && typeof error.message === 'string') { - parts.push(error.message) - } - if ('stderr' in error && typeof error.stderr === 'string') { - parts.push(error.stderr) - } - if ('stdout' in error && typeof error.stdout === 'string') { - parts.push(error.stdout) - } - return parts.join('\n') - } - return String(error) -} - -function isBranchCheckedOutInWorktreeError(error: unknown): boolean { - return /cannot delete branch .*(?:used by worktree|checked out)|branch .*is checked out/i.test( - getErrorText(error) - ) -} +export { removeWorktreeOp } from './git-handler-worktree-remove' async function persistRelayWorktreeCreationBase( git: GitExec, @@ -135,148 +111,12 @@ export async function addWorktreeOp(git: GitExec, params: Record -): Promise { - const worktreePath = params.worktreePath as string - const force = params.force as boolean | undefined - const deleteBranch = params.deleteBranch !== false - const forceBranchDelete = params.forceBranchDelete === true - - let repoPath = worktreePath - try { - const { stdout } = await git(['rev-parse', '--git-common-dir'], worktreePath) - const commonDir = stdout.trim() - if (commonDir && commonDir !== '.git') { - repoPath = path.resolve(worktreePath, commonDir, '..') - } - } catch { - // fall through with worktreePath as repo - } - - const worktreesBeforeRemoval = await listRelayWorktrees(git, repoPath) - const removedWorktree = worktreesBeforeRemoval.find((worktree) => - areRelayWorktreePathsEqual(worktree.path, worktreePath) - ) - const branchName = normalizeLocalBranchRef(removedWorktree?.branch ?? '') - const branchHead = removedWorktree?.head ?? '' - - const args = ['worktree', 'remove'] - if (force) { - args.push('--force') - } - args.push(worktreePath) - await git(args, repoPath) - - if (!branchName) { - return {} - } - if (!deleteBranch) { - return {} - } - - // Why: SSH worktree deletion should mirror local deletion. Dropping the - // branch also removes its upstream config, which lets fork-remotes cleanup - // after the last PR review worktree is gone. - try { - // Why: use `-d` (not `-D`) to mirror the local removeWorktree fix — Git - // refuses to delete a branch with commits not merged into its upstream or - // HEAD, so unpublished work on a remote worktree is preserved rather than - // force-deleted. forceBranchDelete is reserved for failed create rollback. - const branchDeleteResult = await deleteRelayBranchAfterWorktreeRemoval( - git, - repoPath, - branchName, - forceBranchDelete - ) - if (branchDeleteResult === 'checked-out') { - return {} - } - return {} - } catch (error) { - if (!forceBranchDelete && branchHead) { - try { - if ( - await deleteAlreadyMergedRelayBranchAfterSafeDeleteFailure( - git, - repoPath, - branchName, - branchHead - ) - ) { - return {} - } - } catch (alreadyMergedDeleteError) { - // Why: worktree is gone; preserve branch recovery on cleanup races. - console.warn( - `relay removeWorktree: failed to delete already-merged local branch "${branchName}" after removing worktree`, - alreadyMergedDeleteError - ) - } - } - // Expected when the branch still has unmerged/unpublished commits: keep it. - console.warn( - `relay removeWorktree: preserved local branch "${branchName}" after removing worktree (not fully merged)`, - error - ) - return { preservedBranch: { branchName, ...(branchHead ? { head: branchHead } : {}) } } - } -} - -async function deleteRelayBranchAfterWorktreeRemoval( - git: GitExec, - repoPath: string, - branchName: string, - forceBranchDelete: boolean -): Promise<'deleted' | 'checked-out'> { - const deleteFlag = forceBranchDelete ? '-D' : '-d' - try { - await git(['branch', deleteFlag, '--', branchName], repoPath) - return 'deleted' - } catch (error) { - if (!isBranchCheckedOutInWorktreeError(error)) { - throw error - } - } - - try { - // Why: branch deletion is the cheap live-checkout guard. Only prune when - // Git reports a checked-out branch, which may be stale worktree metadata. - await git(['worktree', 'prune'], repoPath) - } catch (error) { - console.warn( - `relay removeWorktree: failed to prune worktrees before deleting branch "${branchName}"`, - error - ) - return 'checked-out' - } - - try { - await git(['branch', deleteFlag, '--', branchName], repoPath) - return 'deleted' - } catch (error) { - if (isBranchCheckedOutInWorktreeError(error)) { - return 'checked-out' - } - throw error - } -} - type RelayWorktreeInfo = { path: string branch?: string head?: string } -async function listRelayWorktrees(git: GitExec, repoPath: string): Promise { - try { - return await readRelayWorktreeList(git, repoPath) - } catch { - return [] - } -} - export async function readRelayWorktreeList( git: GitExec, repoPath: string @@ -305,14 +145,29 @@ function normalizeRelayWorktrees(worktrees: Record[]): RelayWor .filter((worktree) => worktree.path.length > 0) } -function normalizeLocalBranchRef(branch: string): string { - return branch.replace(/^refs\/heads\//, '') +function isPosixAbsolutePath(value: string): boolean { + return value.startsWith('/') +} + +function isWindowsAbsolutePath(value: string): boolean { + return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith('\\\\') +} + +function normalizeRelayWorktreePathForCompare(value: string): string { + if (isPosixAbsolutePath(value)) { + return path.posix.normalize(path.posix.resolve(value)) + } + if (isWindowsAbsolutePath(value)) { + return path.win32.normalize(path.win32.resolve(value)) + } + return path.normalize(path.resolve(value)) } export function areRelayWorktreePathsEqual(leftPath: string, rightPath: string): boolean { - const left = path.normalize(path.resolve(leftPath)) - const right = path.normalize(path.resolve(rightPath)) - return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right + const left = normalizeRelayWorktreePathForCompare(leftPath) + const right = normalizeRelayWorktreePathForCompare(rightPath) + const compareCaseInsensitive = isWindowsAbsolutePath(leftPath) && isWindowsAbsolutePath(rightPath) + return compareCaseInsensitive ? left.toLowerCase() === right.toLowerCase() : left === right } export async function worktreeIsCleanOp( diff --git a/src/relay/git-handler-worktree-remove.ts b/src/relay/git-handler-worktree-remove.ts new file mode 100644 index 00000000000..a698989322c --- /dev/null +++ b/src/relay/git-handler-worktree-remove.ts @@ -0,0 +1,233 @@ +import * as path from 'path' +import type { RemoveWorktreeResult } from '../shared/types' +import { deleteAlreadyMergedRelayBranchAfterSafeDeleteFailure } from './git-handler-branch-cleanup' +import type { GitExec } from './git-handler-ops' +import { isUnsupportedWorktreeListZError, parseWorktreeList } from './git-handler-utils' + +type RelayWorktreeInfo = { + path: string + branch?: string + head?: string +} + +function getErrorText(error: unknown): string { + if (typeof error === 'object' && error !== null) { + const parts: string[] = [] + if ('message' in error && typeof error.message === 'string') { + parts.push(error.message) + } + if ('stderr' in error && typeof error.stderr === 'string') { + parts.push(error.stderr) + } + if ('stdout' in error && typeof error.stdout === 'string') { + parts.push(error.stdout) + } + return parts.join('\n') + } + return String(error) +} + +function isBranchCheckedOutInWorktreeError(error: unknown): boolean { + return /cannot delete branch .*(?:used by worktree|checked out)|branch .*is checked out/i.test( + getErrorText(error) + ) +} + +function normalizeLocalBranchRef(branch: string): string { + return branch.replace(/^refs\/heads\//, '') +} + +function isPosixAbsolutePath(value: string): boolean { + return value.startsWith('/') +} + +function isWindowsAbsolutePath(value: string): boolean { + return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith('\\\\') +} + +function resolveRelayRepoPath(worktreePath: string, commonDir: string): string { + if (isPosixAbsolutePath(worktreePath) || isPosixAbsolutePath(commonDir)) { + // Why: tests can run on Windows while the relay operates on SSH/POSIX + // paths; the default path API would reinterpret "/repo" as "G:\repo". + return path.posix.resolve(worktreePath, commonDir, '..') + } + if (isWindowsAbsolutePath(worktreePath) || isWindowsAbsolutePath(commonDir)) { + return path.win32.resolve(worktreePath, commonDir, '..') + } + return path.resolve(worktreePath, commonDir, '..') +} + +function normalizeRelayWorktreePathForCompare(value: string): string { + if (isPosixAbsolutePath(value)) { + return path.posix.normalize(path.posix.resolve(value)) + } + if (isWindowsAbsolutePath(value)) { + return path.win32.normalize(path.win32.resolve(value)) + } + return path.normalize(path.resolve(value)) +} + +function areRelayWorktreePathsEqual(leftPath: string, rightPath: string): boolean { + const left = normalizeRelayWorktreePathForCompare(leftPath) + const right = normalizeRelayWorktreePathForCompare(rightPath) + const compareCaseInsensitive = isWindowsAbsolutePath(leftPath) && isWindowsAbsolutePath(rightPath) + return compareCaseInsensitive ? left.toLowerCase() === right.toLowerCase() : left === right +} + +function normalizeRelayWorktrees(worktrees: Record[]): RelayWorktreeInfo[] { + return worktrees + .map((worktree) => ({ + path: typeof worktree.path === 'string' ? worktree.path : '', + head: typeof worktree.head === 'string' ? worktree.head : undefined, + branch: typeof worktree.branch === 'string' ? worktree.branch : undefined + })) + .filter((worktree) => worktree.path.length > 0) +} + +async function readRelayWorktreeList(git: GitExec, repoPath: string): Promise { + try { + const { stdout } = await git(['worktree', 'list', '--porcelain', '-z'], repoPath) + return normalizeRelayWorktrees(parseWorktreeList(stdout, { nulDelimited: true })) + } catch (error) { + if (!isUnsupportedWorktreeListZError(error)) { + throw error + } + } + + // Why: `-z` preserves newlines; fallback keeps Git <2.36 compatible. + const { stdout } = await git(['worktree', 'list', '--porcelain'], repoPath) + return normalizeRelayWorktrees(parseWorktreeList(stdout)) +} + +async function listRelayWorktreesForRemoval(git: GitExec, repoPath: string) { + try { + return await readRelayWorktreeList(git, repoPath) + } catch { + return [] + } +} + +async function deleteRelayBranchAfterWorktreeRemoval( + git: GitExec, + repoPath: string, + branchName: string, + forceBranchDelete: boolean +): Promise<'deleted' | 'checked-out'> { + const deleteFlag = forceBranchDelete ? '-D' : '-d' + try { + await git(['branch', deleteFlag, '--', branchName], repoPath) + return 'deleted' + } catch (error) { + if (!isBranchCheckedOutInWorktreeError(error)) { + throw error + } + } + + try { + // Why: branch deletion is the cheap live-checkout guard. Only prune when + // Git reports a checked-out branch, which may be stale worktree metadata. + await git(['worktree', 'prune'], repoPath) + } catch (error) { + console.warn( + `relay removeWorktree: failed to prune worktrees before deleting branch "${branchName}"`, + error + ) + return 'checked-out' + } + + try { + await git(['branch', deleteFlag, '--', branchName], repoPath) + return 'deleted' + } catch (error) { + if (isBranchCheckedOutInWorktreeError(error)) { + return 'checked-out' + } + throw error + } +} + +export async function removeWorktreeOp( + git: GitExec, + params: Record +): Promise { + const worktreePath = params.worktreePath as string + const force = params.force as boolean | undefined + const deleteBranch = params.deleteBranch !== false + const forceBranchDelete = params.forceBranchDelete === true + + let repoPath = worktreePath + try { + const { stdout } = await git(['rev-parse', '--git-common-dir'], worktreePath) + const commonDir = stdout.trim() + if (commonDir && commonDir !== '.git') { + repoPath = resolveRelayRepoPath(worktreePath, commonDir) + } + } catch { + // fall through with worktreePath as repo + } + + const worktreesBeforeRemoval = await listRelayWorktreesForRemoval(git, repoPath) + const removedWorktree = worktreesBeforeRemoval.find((worktree) => + areRelayWorktreePathsEqual(worktree.path, worktreePath) + ) + const branchName = normalizeLocalBranchRef(removedWorktree?.branch ?? '') + const branchHead = removedWorktree?.head ?? '' + + const args = ['worktree', 'remove'] + if (force) { + args.push('--force') + } + args.push(worktreePath) + await git(args, repoPath) + + if (!branchName) { + return {} + } + if (!deleteBranch) { + return {} + } + + // Why: SSH worktree deletion should mirror local deletion. Dropping the + // branch also removes its upstream config, which lets fork-remotes cleanup + // after the last PR review worktree is gone. + try { + // Why: use `-d` (not `-D`) to mirror the local removeWorktree fix. + const branchDeleteResult = await deleteRelayBranchAfterWorktreeRemoval( + git, + repoPath, + branchName, + forceBranchDelete + ) + if (branchDeleteResult === 'checked-out') { + return {} + } + return {} + } catch (error) { + if (!forceBranchDelete && branchHead) { + try { + if ( + await deleteAlreadyMergedRelayBranchAfterSafeDeleteFailure( + git, + repoPath, + branchName, + branchHead + ) + ) { + return {} + } + } catch (alreadyMergedDeleteError) { + // Why: worktree is gone; preserve branch recovery on cleanup races. + console.warn( + `relay removeWorktree: failed to delete already-merged local branch "${branchName}" after removing worktree`, + alreadyMergedDeleteError + ) + } + } + // Expected when the branch still has unmerged/unpublished commits: keep it. + console.warn( + `relay removeWorktree: preserved local branch "${branchName}" after removing worktree (not fully merged)`, + error + ) + return { preservedBranch: { branchName, ...(branchHead ? { head: branchHead } : {}) } } + } +} diff --git a/src/relay/git-handler.test.ts b/src/relay/git-handler.test.ts index 5d95980a536..c2a8eab2bf4 100644 --- a/src/relay/git-handler.test.ts +++ b/src/relay/git-handler.test.ts @@ -59,6 +59,10 @@ describe('GitHandler', () => { ) } + function normalizeGitFileText(content: string): string { + return content.replace(/\r\n/g, '\n') + } + it('registers all expected handlers', () => { const methods = Array.from(dispatcher._requestHandlers.keys()) expect(methods).toContain('git.status') @@ -124,7 +128,9 @@ describe('GitHandler', () => { await dispatcher.callRequest('git.abortMerge', { worktreePath: tmpDir }) await expect(fs.access(path.join(tmpDir, '.git', 'MERGE_HEAD'))).rejects.toThrow() - await expect(fs.readFile(path.join(tmpDir, 'file.txt'), 'utf-8')).resolves.toBe('main\n') + await expect( + fs.readFile(path.join(tmpDir, 'file.txt'), 'utf-8').then(normalizeGitFileText) + ).resolves.toBe('main\n') }) }) @@ -155,7 +161,9 @@ describe('GitHandler', () => { await expect(fs.access(path.join(tmpDir, '.git', 'rebase-merge'))).rejects.toThrow() await expect(fs.access(path.join(tmpDir, '.git', 'rebase-apply'))).rejects.toThrow() - await expect(fs.readFile(path.join(tmpDir, 'file.txt'), 'utf-8')).resolves.toBe('feature\n') + await expect( + fs.readFile(path.join(tmpDir, 'file.txt'), 'utf-8').then(normalizeGitFileText) + ).resolves.toBe('feature\n') }) }) @@ -579,28 +587,28 @@ describe('GitHandler', () => { gitInit(tmpDir) writeFileSync(path.join(tmpDir, '.gitignore'), 'ignored.log\n') gitCommit(tmpDir, 'initial') - writeFileSync(path.join(tmpDir, '*.log'), 'selected') + writeFileSync(path.join(tmpDir, '[k]eep.log'), 'selected') writeFileSync(path.join(tmpDir, 'keep.log'), 'unrelated') writeFileSync(path.join(tmpDir, 'ignored.log'), 'ignored') - await dispatcher.callRequest('git.discard', { worktreePath: tmpDir, filePath: '*.log' }) + await dispatcher.callRequest('git.discard', { worktreePath: tmpDir, filePath: '[k]eep.log' }) - await expect(fs.access(path.join(tmpDir, '*.log'))).rejects.toThrow() + await expect(fs.access(path.join(tmpDir, '[k]eep.log'))).rejects.toThrow() await expect(fs.access(path.join(tmpDir, 'keep.log'))).resolves.toBeUndefined() await expect(fs.access(path.join(tmpDir, 'ignored.log'))).resolves.toBeUndefined() }) it('treats tracked discard paths with Git glob characters as literal paths', async () => { gitInit(tmpDir) - writeFileSync(path.join(tmpDir, '*.log'), 'selected') + writeFileSync(path.join(tmpDir, '[k]eep.log'), 'selected') writeFileSync(path.join(tmpDir, 'keep.log'), 'keep') gitCommit(tmpDir, 'track log fixtures') - writeFileSync(path.join(tmpDir, '*.log'), 'selected modified') + writeFileSync(path.join(tmpDir, '[k]eep.log'), 'selected modified') writeFileSync(path.join(tmpDir, 'keep.log'), 'keep modified') - await dispatcher.callRequest('git.discard', { worktreePath: tmpDir, filePath: '*.log' }) + await dispatcher.callRequest('git.discard', { worktreePath: tmpDir, filePath: '[k]eep.log' }) - await expect(fs.readFile(path.join(tmpDir, '*.log'), 'utf-8')).resolves.toBe('selected') + await expect(fs.readFile(path.join(tmpDir, '[k]eep.log'), 'utf-8')).resolves.toBe('selected') await expect(fs.readFile(path.join(tmpDir, 'keep.log'), 'utf-8')).resolves.toBe( 'keep modified' ) diff --git a/src/relay/relay-handshake-roundtrip.test.ts b/src/relay/relay-handshake-roundtrip.test.ts index 28d884940b0..6650a093f40 100644 --- a/src/relay/relay-handshake-roundtrip.test.ts +++ b/src/relay/relay-handshake-roundtrip.test.ts @@ -16,6 +16,7 @@ import { type DecodedFrame, MessageType } from './protocol' +import { relayTestSocketPath } from './relay-test-socket-path' // Why: --connect normally calls process.exit on mismatch / fatal handshake // errors. Stub it for tests so the harness sees a thrown sentinel error @@ -38,7 +39,7 @@ describe('handshake round-trip over a real Socket pair', () => { beforeEach(() => { tmpDir = mkdtempSync(join(tmpdir(), 'orca-handshake-test-')) - sockPath = join(tmpDir, 'relay.sock') + sockPath = relayTestSocketPath(tmpDir) exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { throw new ExitCalled(code ?? 0) }) as never) diff --git a/src/relay/relay-test-socket-path.ts b/src/relay/relay-test-socket-path.ts new file mode 100644 index 00000000000..f412d0670d8 --- /dev/null +++ b/src/relay/relay-test-socket-path.ts @@ -0,0 +1,17 @@ +import { createHash } from 'crypto' +import { join } from 'path' + +export function relayTestSocketPath(dir: string, name = 'relay.sock'): string { + if (process.platform !== 'win32') { + return join(dir, name) + } + + const suffix = createHash('sha256') + .update(`${process.pid}\0${dir}\0${name}`) + .digest('hex') + .slice(0, 20) + + // Why: Node's net server requires Windows IPC endpoints to be named pipes; + // filesystem-style .sock paths fail with EACCES on Windows hosts. + return `\\\\.\\pipe\\orca-relay-test-${suffix}` +} diff --git a/src/relay/subprocess.test.ts b/src/relay/subprocess.test.ts index ced23197a95..d6469b18085 100644 --- a/src/relay/subprocess.test.ts +++ b/src/relay/subprocess.test.ts @@ -8,6 +8,7 @@ import { execFileSync, spawn as spawnChild } from 'child_process' import { build } from 'esbuild' import { spawnRelay, type RelayProcess } from './subprocess-test-utils' import { getEndpointFileName } from '../shared/agent-hook-listener' +import { relayTestSocketPath } from './relay-test-socket-path' const RELAY_TS_ENTRY = path.resolve(__dirname, 'relay.ts') let bundleDir: string @@ -40,7 +41,13 @@ function spawn(args: string[] = [], env?: NodeJS.ProcessEnv): RelayProcess { if (!args.includes('--sock-path')) { const socketDir = mkdtempSync(path.join(tmpdir(), 'relay-sock-')) spawnedSocketDirs.push(socketDir) - relayArgs = [...args, '--sock-path', path.join(socketDir, 'relay.sock')] + relayArgs = [ + ...args, + '--sock-path', + relayTestSocketPath(socketDir), + '--endpoint-dir', + path.join(socketDir, 'agent-hooks') + ] } return spawnRelay(relayEntry, relayArgs, env ? { env } : undefined) } diff --git a/src/renderer/src/components/PullRequestPage.tsx b/src/renderer/src/components/PullRequestPage.tsx index db4736cd8cb..4154b4e482e 100644 --- a/src/renderer/src/components/PullRequestPage.tsx +++ b/src/renderer/src/components/PullRequestPage.tsx @@ -160,6 +160,8 @@ import { } from '@/lib/github-work-item-workspace-attachment' import { startFixChecksAgent } from '@/lib/fix-checks-agent-launch' import { launchWorkItemDirect } from '@/lib/launch-work-item-direct' +import { getLocalRepoProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' +import { CLIENT_PLATFORM } from '@/lib/new-workspace' import { readSourceControlLaunchRecipeAgentId } from '@/lib/source-control-launch-agent-selection' import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform' import { activateAndRevealWorktree } from '@/lib/worktree-activation' @@ -3978,9 +3980,16 @@ function ChecksTab({ () => resolveSourceControlLaunchPlatform({ connectionId: repo?.connectionId ?? null, - worktreePath: repo?.path ?? null + worktreePath: repo?.path ?? null, + projectRuntime: repo?.connectionId + ? undefined + : getLocalRepoProjectExecutionRuntimeContext( + useAppStore.getState(), + repo?.id, + CLIENT_PLATFORM + ) }), - [repo?.connectionId, repo?.path] + [repo?.connectionId, repo?.id, repo?.path] ) const saveFixChecksActionDefault = useCallback( async ( diff --git a/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts b/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts index a890bce3bb7..79ea48d8929 100644 --- a/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts +++ b/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts @@ -16,10 +16,6 @@ function sourceBetween(source: string, startPattern: string, endPattern: string) return source.slice(start, end) } -function componentBodyBeforeRender(source: string, componentName: string): string { - return sourceBetween(source, `function ${componentName}`, '\n return (\n <') -} - describe('feature interaction writer boundaries', () => { it('keeps Cmd+J feature writers in open/selection handlers, not query or navigation rendering', () => { const source = componentSource('WorktreeJumpPalette.tsx') @@ -45,11 +41,7 @@ describe('feature interaction writer boundaries', () => { const passiveSections = [ sourceBetween(source, 'const handleRefreshGithubTasks', 'const [newIssueOpen'), - sourceBetween( - source, - 'const handleLoadNextPage', - 'useEffect(() => {\n if (!taskResumeApplied)' - ), + sourceBetween(source, 'const handleLoadNextPage', 'const handleApplyTaskSearch'), sourceBetween(source, 'const handleApplyTaskSearch', 'const handleSetDefaultTaskPreset'), sourceBetween(source, 'const handleSelectGithubTaskKind', 'const handleResetGithubTaskSearch') ] @@ -64,7 +56,7 @@ describe('feature interaction writer boundaries', () => { const mutationSections = [ sourceBetween(source, 'function GHAssigneesCell', 'const triggerContent ='), sourceBetween(source, 'function PRReviewCell', 'const requestReviewer ='), - componentBodyBeforeRender(source, 'PRMergeCell'), + sourceBetween(source, 'function PRMergeCell', 'const handleAutoMerge'), sourceBetween( source, 'const handleOpenOrUseGitHubWorkItem', @@ -83,7 +75,7 @@ describe('feature interaction writer boundaries', () => { sourceBetween(source, 'function GHStatusCell', 'function GitHubAssigneeAvatar'), sourceBetween(source, 'function GHAssigneesCell', 'const triggerContent ='), sourceBetween(source, 'function PRReviewCell', 'function PRChecksCell'), - componentBodyBeforeRender(source, 'PRMergeCell'), + sourceBetween(source, 'function PRMergeCell', 'const handleAutoMerge'), sourceBetween(source, 'const handleCreateNewIssue', 'const handleCreateNewLinearProject') ] diff --git a/src/renderer/src/components/feature-wall/BrowserUseSkillSetupCard.tsx b/src/renderer/src/components/feature-wall/BrowserUseSkillSetupCard.tsx index 12fd9c5d359..549ea66ceeb 100644 --- a/src/renderer/src/components/feature-wall/BrowserUseSkillSetupCard.tsx +++ b/src/renderer/src/components/feature-wall/BrowserUseSkillSetupCard.tsx @@ -6,7 +6,13 @@ import { } from '@/lib/agent-skill-cli-prerequisite' import { BROWSER_USE_ENABLED_STORAGE_KEY } from '@/lib/browser-use-setup-state' import type { InstalledAgentSkillState } from '@/hooks/useInstalledAgentSkills' +import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime' import { AgentSkillSetupPanel } from '@/components/settings/AgentSkillSetupPanel' +import { + buildSkillInstallCommandForRuntime, + ensureWslCliAvailableForAgentSkillTerminal, + getWslCliDistroRequest +} from '@/components/settings/CliSkillRuntimeSetup' import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' @@ -16,10 +22,20 @@ export function BrowserUseSkillSetupCard(props: { skill: InstalledAgentSkillState }): JSX.Element { const { compact, terminalHeightPx, skill } = props + const activeSkillRuntime = useActiveProjectSkillRuntime() + const installCommand = + activeSkillRuntime.agentRuntime && !activeSkillRuntime.installDisabledReason + ? buildSkillInstallCommandForRuntime( + ORCA_CLI_SKILL_INSTALL_COMMAND, + activeSkillRuntime.agentRuntime + ) + : ORCA_CLI_SKILL_INSTALL_COMMAND const handleBeforeOpenTerminal = async (): Promise => { useAppStore.getState().recordFeatureInteraction('agent-browser-setup') - await ensureOrcaCliAvailableForAgentSkillTerminal() + await (activeSkillRuntime.agentRuntime?.runtime === 'wsl' + ? ensureWslCliAvailableForAgentSkillTerminal(activeSkillRuntime.agentRuntime) + : ensureOrcaCliAvailableForAgentSkillTerminal()) localStorage.setItem(BROWSER_USE_ENABLED_STORAGE_KEY, '1') } @@ -34,15 +50,24 @@ export function BrowserUseSkillSetupCard(props: { 'auto.components.feature.wall.BrowserUseSkillSetupCard.cbc45022d4', "Enables agents to navigate and verify pages in Orca's browser." )} - command={ORCA_CLI_SKILL_INSTALL_COMMAND} + command={installCommand} terminalTitle="Browser Use setup" terminalAriaLabel="Browser Use skill install terminal" terminalWorktreeId="feature-wall-browser-use-skill-terminal" + terminalShellOverride={activeSkillRuntime.terminalShellOverride} installed={skill.installed} loading={skill.loading} - error={skill.error} + error={activeSkillRuntime.installDisabledReason ?? skill.error} + installDisabled={Boolean(activeSkillRuntime.installDisabledReason)} terminalHeightPx={terminalHeightPx} preInstallNotice={AGENT_SKILL_CLI_PREREQUISITE_NOTICE} + getPrerequisiteStatus={() => + activeSkillRuntime.agentRuntime?.runtime === 'wsl' + ? window.api.cli.getWslInstallStatus( + getWslCliDistroRequest(activeSkillRuntime.agentRuntime) + ) + : window.api.cli.getInstallStatus() + } onBeforeOpenTerminal={handleBeforeOpenTerminal} showRecheckWhenInstalled={false} onRecheck={skill.refresh} diff --git a/src/renderer/src/components/feature-wall/FeatureWallTourSurface.tsx b/src/renderer/src/components/feature-wall/FeatureWallTourSurface.tsx index d581b000fcf..58671db4838 100644 --- a/src/renderer/src/components/feature-wall/FeatureWallTourSurface.tsx +++ b/src/renderer/src/components/feature-wall/FeatureWallTourSurface.tsx @@ -19,6 +19,7 @@ import { GLOBAL_AGENT_SKILL_SOURCE_KINDS, useInstalledAgentSkill } from '@/hooks/useInstalledAgentSkills' +import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime' import { usePrefersReducedMotion } from './feature-wall-modal-helpers' import { toFeatureWallAssetUrl, useFeatureWallAssetBaseUrl } from './feature-wall-assets' import { useFeatureWallTaskSourcePresentation } from './use-feature-wall-task-source-presentation' @@ -62,6 +63,7 @@ export function FeatureWallTourSurface({ }: FeatureWallTourSurfaceProps): JSX.Element | null { const settings = useAppStore((s) => s.settings) const updateSettings = useAppStore((s) => s.updateSettings) + const activeSkillRuntime = useActiveProjectSkillRuntime() const assetBaseUrl = useFeatureWallAssetBaseUrl(isOpen) const prefersReducedMotion = usePrefersReducedMotion() const reactId = useId() @@ -109,10 +111,12 @@ export function FeatureWallTourSurface({ // from passive Effects. const orchestrationSkill = useInstalledAgentSkill(ORCHESTRATION_SKILL_NAME, { enabled: isOpen, + discoveryTarget: activeSkillRuntime.discoveryTarget, sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS }) const browserUseSkill = useInstalledAgentSkill(ORCA_CLI_SKILL_NAME, { enabled: isOpen, + discoveryTarget: activeSkillRuntime.discoveryTarget, sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS }) const completion = useFeatureWallCompletion( diff --git a/src/renderer/src/components/feature-wall/agent-capability-setup-status.ts b/src/renderer/src/components/feature-wall/agent-capability-setup-status.ts index 835ab9f3763..9f4fcb31da7 100644 --- a/src/renderer/src/components/feature-wall/agent-capability-setup-status.ts +++ b/src/renderer/src/components/feature-wall/agent-capability-setup-status.ts @@ -12,6 +12,7 @@ import { GLOBAL_AGENT_SKILL_SOURCE_KINDS, useInstalledAgentSkill } from '@/hooks/useInstalledAgentSkills' +import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime' import { translate } from '@/i18n/i18n' export type AgentCapabilityInstallStatusTone = 'ready' | 'pending' | 'checking' | 'error' @@ -40,13 +41,17 @@ export type AgentCapabilitySetupStatus = { } export function useAgentCapabilitySetupStatus(): AgentCapabilitySetupStatus { + const activeSkillRuntime = useActiveProjectSkillRuntime() const browserUseSkill = useInstalledAgentSkill(ORCA_CLI_SKILL_NAME, { + discoveryTarget: activeSkillRuntime.discoveryTarget, sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS }) const computerUseSkill = useInstalledAgentSkill(COMPUTER_USE_SKILL_NAME, { + discoveryTarget: activeSkillRuntime.discoveryTarget, sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS }) const orchestrationSkill = useInstalledAgentSkill(ORCHESTRATION_SKILL_NAME, { + discoveryTarget: activeSkillRuntime.discoveryTarget, sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS }) const computerUsePermissionStatus = useComputerUsePermissionStatus(computerUseSkill.installed) diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalOrchestrationDialog.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalOrchestrationDialog.tsx index e16de1e41ef..8ebee89490d 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalOrchestrationDialog.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalOrchestrationDialog.tsx @@ -18,7 +18,13 @@ import { GLOBAL_AGENT_SKILL_SOURCE_KINDS, useInstalledAgentSkill } from '@/hooks/useInstalledAgentSkills' +import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime' import { useAppStore } from '@/store' +import { + buildSkillInstallCommandForRuntime, + ensureWslCliAvailableForAgentSkillTerminal, + getWslCliDistroRequest +} from '@/components/settings/CliSkillRuntimeSetup' import { translate } from '@/i18n/i18n' type FloatingTerminalOrchestrationDialogProps = { @@ -32,6 +38,14 @@ export function FloatingTerminalOrchestrationDialog({ onOpenChange, onSetupStateChange }: FloatingTerminalOrchestrationDialogProps): React.JSX.Element { + const activeSkillRuntime = useActiveProjectSkillRuntime() + const installCommand = + activeSkillRuntime.agentRuntime && !activeSkillRuntime.installDisabledReason + ? buildSkillInstallCommandForRuntime( + ORCHESTRATION_SKILL_INSTALL_COMMAND, + activeSkillRuntime.agentRuntime + ) + : ORCHESTRATION_SKILL_INSTALL_COMMAND const { installed: orchestrationSkillDetected, loading: orchestrationSkillLoading, @@ -39,6 +53,7 @@ export function FloatingTerminalOrchestrationDialog({ refresh: refreshOrchestrationSkill } = useInstalledAgentSkill(ORCHESTRATION_SKILL_NAME, { enabled: open, + discoveryTarget: activeSkillRuntime.discoveryTarget, sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS }) @@ -103,20 +118,31 @@ export function FloatingTerminalOrchestrationDialog({ 'auto.components.floating.terminal.FloatingTerminalOrchestrationDialog.f726054620', 'Enables agents to hand off context and coordinate work through Orca.' )} - command={ORCHESTRATION_SKILL_INSTALL_COMMAND} + command={installCommand} terminalTitle="Orchestration setup" terminalAriaLabel="Orchestration skill install terminal" terminalWorktreeId="floating-terminal-orchestration-skill-terminal" + terminalShellOverride={activeSkillRuntime.terminalShellOverride} installed={orchestrationSkillDetected} loading={orchestrationSkillLoading} - error={orchestrationSkillError} + error={activeSkillRuntime.installDisabledReason ?? orchestrationSkillError} + installDisabled={Boolean(activeSkillRuntime.installDisabledReason)} variant="inline" hideHeader installLabel="Install CLI & skill" preInstallNotice={AGENT_SKILL_CLI_PREREQUISITE_NOTICE} + getPrerequisiteStatus={() => + activeSkillRuntime.agentRuntime?.runtime === 'wsl' + ? window.api.cli.getWslInstallStatus( + getWslCliDistroRequest(activeSkillRuntime.agentRuntime) + ) + : window.api.cli.getInstallStatus() + } onBeforeOpenTerminal={async () => { useAppStore.getState().recordFeatureInteraction('agent-orchestration-setup') - await ensureOrcaCliAvailableForAgentSkillTerminal() + await (activeSkillRuntime.agentRuntime?.runtime === 'wsl' + ? ensureWslCliAvailableForAgentSkillTerminal(activeSkillRuntime.agentRuntime) + : ensureOrcaCliAvailableForAgentSkillTerminal()) }} onRecheck={refreshOrchestrationSkill} /> diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx index c96cc6fe0e0..1decb8a9454 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx @@ -1,13 +1,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' -import { CLIENT_PLATFORM } from '@/lib/new-workspace' +import { buildAiVaultResumeCommandForWorktree } from '@/lib/ai-vault-resume-command' import { launchAiVaultSessionInNewTab } from '@/lib/launch-ai-vault-session' import { useAppStore } from '@/store' import { useActiveWorktree, useRepoById } from '@/store/selectors' import { agentLabel, filterAiVaultSessions, groupAiVaultSessions } from './ai-vault-session-filters' import { AI_VAULT_AGENTS, - buildAiVaultResumeCommand, type AiVaultAgent, type AiVaultGroup, type AiVaultListResult, @@ -121,15 +120,13 @@ export default function AiVaultPanel(): React.JSX.Element { const buildResumeCommand = useCallback( (session: AiVaultSession): string => - buildAiVaultResumeCommand({ - agent: session.agent, - sessionId: session.sessionId, - cwd: session.cwd, - platform: CLIENT_PLATFORM, - commandOverride: agentCmdOverrides[session.agent], - codexHome: session.codexHome + buildAiVaultResumeCommandForWorktree({ + state: useAppStore.getState(), + worktreeId: activeWorktree?.id ?? null, + session, + commandOverride: agentCmdOverrides[session.agent] }), - [agentCmdOverrides] + [activeWorktree?.id, agentCmdOverrides] ) const copyResumeCommand = useCallback( diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index cf53a13aac7..871bfbca678 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -132,6 +132,7 @@ import { type SourceControlAiWriteTarget } from '../../../../shared/source-control-ai-recipe-save' import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform' +import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { CreateHostedReviewComposer } from './CreateHostedReviewComposer' import { formatCreateError } from './create-pull-request-review-copy' @@ -472,7 +473,10 @@ export default function ChecksPanel(): React.JSX.Element { const activeWorktreePushTarget = activeWorktree?.pushTarget ?? null const activeSourceControlLaunchPlatform = resolveSourceControlLaunchPlatform({ connectionId: activeConnectionId, - worktreePath: activeWorktreePath + worktreePath: activeWorktreePath, + projectRuntime: activeConnectionId + ? undefined + : getLocalProjectExecutionRuntimeContext(useAppStore.getState(), activeWorktreeId) }) const runtimeEnvironmentId = useAppStore((s) => getRuntimeEnvironmentIdForWorktree(s, activeWorktreeId) diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index d54af41bf7e..ba5b60d7cdd 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -183,6 +183,7 @@ import { import type { SourceControlAiWriteTarget } from '../../../../shared/source-control-ai-recipe-save' import { getWorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display' import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform' +import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { getCommitFailureDialogWorktreeKey, shouldShowCommitFailureDialog, @@ -1037,7 +1038,10 @@ function SourceControlInner(): React.JSX.Element { : null const activeSourceControlLaunchPlatform = resolveSourceControlLaunchPlatform({ connectionId: activeConnectionId, - worktreePath + worktreePath, + projectRuntime: activeConnectionId + ? undefined + : getLocalProjectExecutionRuntimeContext(useAppStore.getState(), activeWorktreeId) }) const gitIdentityDisplay = activeWorktree ? getWorktreeGitIdentityDisplay(activeWorktree) : null const detachedHeadDisplay = gitIdentityDisplay?.kind === 'detached' ? gitIdentityDisplay : null diff --git a/src/renderer/src/components/settings/AgentLocationSetting.tsx b/src/renderer/src/components/settings/AgentLocationSetting.tsx deleted file mode 100644 index fa7d1c072f9..00000000000 --- a/src/renderer/src/components/settings/AgentLocationSetting.tsx +++ /dev/null @@ -1,175 +0,0 @@ -import type { GlobalSettings } from '../../../../shared/types' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' -import { SettingsRow, SettingsSegmentedControl } from './SettingsFormControls' -import { translate } from '@/i18n/i18n' - -const EMPTY_WSL_DISTROS: string[] = [] - -type AgentDetectionRuntime = { - runtime: 'host' | 'wsl' - wslDistro?: string | null - label: string -} - -type AgentLocationSettingProps = { - settings: GlobalSettings - updateSettings: (updates: Partial) => void | Promise - refresh: () => Promise - wslSupportedPlatform?: boolean - wslAvailable?: boolean - wslDistros?: string[] - wslCapabilitiesLoading?: boolean -} - -function getHostRuntimeLabel(): string { - return navigator.userAgent.includes('Windows') ? 'Windows' : 'This device' -} - -function getSelectedAgentRuntime( - settings: GlobalSettings, - wslSupportedPlatform: boolean, - wslAvailable: boolean, - wslDistros: string[], - wslCapabilitiesLoading: boolean -): AgentDetectionRuntime { - const configuredRuntime = - settings.localAgentRuntime ?? (settings.terminalWindowsShell === 'wsl.exe' ? 'wsl' : 'host') - if (wslSupportedPlatform && configuredRuntime === 'wsl') { - if (!wslAvailable && !wslCapabilitiesLoading) { - return { - runtime: 'wsl', - label: translate('auto.components.settings.AgentLocationSetting.43663b5e69', 'WSL') - } - } - const configuredDistro = - settings.localAgentWslDistro?.trim() || settings.terminalWindowsWslDistro?.trim() || null - const selectedDistro = - configuredDistro && (wslCapabilitiesLoading || wslDistros.includes(configuredDistro)) - ? configuredDistro - : null - return { - runtime: 'wsl', - wslDistro: selectedDistro, - label: selectedDistro ? `WSL ${selectedDistro}` : 'WSL default' - } - } - return { runtime: 'host', label: getHostRuntimeLabel() } -} - -export function AgentLocationSetting({ - settings, - updateSettings, - refresh, - wslSupportedPlatform = false, - wslAvailable = false, - wslDistros = EMPTY_WSL_DISTROS, - wslCapabilitiesLoading = false -}: AgentLocationSettingProps): React.JSX.Element | null { - const agentRuntime = getSelectedAgentRuntime( - settings, - wslSupportedPlatform, - wslAvailable, - wslDistros, - wslCapabilitiesLoading - ) - const updateAgentLocation = (updates: Partial): void => { - void Promise.resolve(updateSettings(updates)).then(() => refresh()) - } - - if (!wslSupportedPlatform) { - return null - } - - return ( -
- - updateAgentLocation({ localAgentRuntime: value })} - equalWidth - options={[ - { value: 'host', label: getHostRuntimeLabel() }, - ...(wslSupportedPlatform - ? [ - { - value: 'wsl', - label: translate( - 'auto.components.settings.AgentLocationSetting.43663b5e69', - 'WSL' - ), - disabled: wslCapabilitiesLoading || !wslAvailable - } as const - ] - : []) - ]} - /> - {wslSupportedPlatform && agentRuntime.runtime === 'wsl' ? ( - - ) : null} - - } - /> -
- ) -} diff --git a/src/renderer/src/components/settings/AgentsPane.test.tsx b/src/renderer/src/components/settings/AgentsPane.test.tsx index 82fd2e87d4c..0ed91de7a58 100644 --- a/src/renderer/src/components/settings/AgentsPane.test.tsx +++ b/src/renderer/src/components/settings/AgentsPane.test.tsx @@ -147,7 +147,7 @@ describe('AgentsPane', () => { expect(markup).toContain('aria-checked="false"') }) - it('keeps the agent location aligned with a WSL default terminal while capabilities load', () => { + it('does not render the legacy agent location control on Windows', () => { const markup = renderPane( { ...getDefaultSettings('/tmp'), @@ -156,8 +156,9 @@ describe('AgentsPane', () => { { wslSupportedPlatform: true, wslCapabilitiesLoading: true } ) - expect(markup).toContain('Show installed agents from WSL default.') - expect(markup).toContain('role="radio" aria-checked="true" disabled=""') + expect(markup).not.toContain('Agent location') + expect(markup).not.toContain('aria-label="Agent location"') + expect(markup).not.toContain('Show installed agents from WSL default.') }) it('hides the WSL agent location controls on platforms without WSL support', () => { @@ -374,9 +375,11 @@ describe('AgentsPane', () => { }) }) - it('includes agent location search metadata', () => { - expect(matchesSettingsSearch('wsl', getAgentsPaneSearchEntries())).toBe(true) - expect(matchesSettingsSearch('windows', getAgentsPaneSearchEntries())).toBe(true) + it('does not include legacy agent location search metadata', () => { + expect(matchesSettingsSearch('agent location', getAgentsPaneSearchEntries())).toBe(false) + expect(matchesSettingsSearch('installed agents in wsl', getAgentsPaneSearchEntries())).toBe( + false + ) }) it('serializes rapid availability writes against the latest settings snapshot', async () => { diff --git a/src/renderer/src/components/settings/AgentsPane.tsx b/src/renderer/src/components/settings/AgentsPane.tsx index 93be35b36bb..42b34232137 100644 --- a/src/renderer/src/components/settings/AgentsPane.tsx +++ b/src/renderer/src/components/settings/AgentsPane.tsx @@ -1,6 +1,6 @@ /* eslint-disable max-lines -- Why: the Agents pane keeps catalog rows, default - selection, per-agent controls, and runtime location together so settings - reconciliation stays visible in one file. */ + selection, and per-agent controls together so settings reconciliation stays + visible in one file. */ import { useMemo, useState } from 'react' import { Check, ChevronDown, ExternalLink, Info, RefreshCw, Terminal } from 'lucide-react' import type { GlobalSettings, TuiAgent } from '../../../../shared/types' @@ -15,7 +15,6 @@ import { getAgentGeneratedTabTitlesDescription, getAgentGeneratedTabTitlesTitle } from './agent-generated-tab-title-copy' -import { AgentLocationSetting } from './AgentLocationSetting' import { getAgentStatusHooksDescription, getAgentStatusHooksTitle } from './agent-status-hooks-copy' import { SettingsBadge, @@ -44,11 +43,10 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' export { getAgentsPaneSearchEntries } from './agents-search' -const EMPTY_WSL_DISTROS: string[] = [] - type AgentsPaneProps = { settings: GlobalSettings updateSettings: (updates: Partial) => void | Promise + /** Deprecated: agent detection now follows the resolved project runtime. */ wslSupportedPlatform?: boolean wslAvailable?: boolean wslDistros?: string[] @@ -673,14 +671,7 @@ function DefaultAgentPill({ active, onClick, children }: DefaultAgentPillProps): ) } -export function AgentsPane({ - settings, - updateSettings, - wslSupportedPlatform = false, - wslAvailable = false, - wslDistros = EMPTY_WSL_DISTROS, - wslCapabilitiesLoading = false -}: AgentsPaneProps): React.JSX.Element { +export function AgentsPane({ settings, updateSettings }: AgentsPaneProps): React.JSX.Element { const { detectedIds: detectedList, isRefreshing, refresh } = useDetectedAgents() // Why: refresh re-spawns the user's login shell to re-capture PATH // (preflight:refreshAgents on the main side). This handles the @@ -779,16 +770,6 @@ export function AgentsPane({ return (
- -
{ + (nextStatus: CliInstallStatus | null): void => { if (mountedRef.current) { setCliStatus(nextStatus) } @@ -73,7 +87,17 @@ export function BrowserUseSetup({ const refreshCli = useCallback(async (): Promise => { setCliLoading(true) try { - handleCliStatusChange(await window.api.cli.getInstallStatus()) + if (activeSkillRuntime.installDisabledReason) { + handleCliStatusChange(null) + return + } + const nextStatus = + activeSkillRuntime.agentRuntime?.runtime === 'wsl' + ? await window.api.cli.getWslInstallStatus( + getWslCliDistroRequest(activeSkillRuntime.agentRuntime) + ) + : await window.api.cli.getInstallStatus() + handleCliStatusChange(nextStatus) } catch (error) { if (mountedRef.current) { toast.error( @@ -90,7 +114,7 @@ export function BrowserUseSetup({ setCliLoading(false) } } - }, [handleCliStatusChange, mountedRef]) + }, [activeSkillRuntime, handleCliStatusChange, mountedRef]) useEffect(() => { if (!browserUseEnabled) { @@ -114,15 +138,25 @@ export function BrowserUseSetup({ refresh: refreshSkill } = useInstalledAgentSkill(ORCA_CLI_SKILL_NAME, { enabled: browserUseEnabled, + discoveryTarget: activeSkillRuntime.discoveryTarget, sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS }) const handleEnableCli = async (): Promise => { + if (activeSkillRuntime.installDisabledReason) { + return + } setCliBusy(true) try { - const next = await ensureOrcaCliAvailableForAgentSkillTerminal({ - onStatusChange: handleCliStatusChange - }) + const next = + activeSkillRuntime.agentRuntime?.runtime === 'wsl' + ? await ensureWslCliAvailableForAgentSkillTerminal(activeSkillRuntime.agentRuntime) + : await ensureOrcaCliAvailableForAgentSkillTerminal({ + onStatusChange: handleCliStatusChange + }) + if (activeSkillRuntime.agentRuntime?.runtime === 'wsl') { + handleCliStatusChange(next) + } if (mountedRef.current && isOrcaCliAvailableOnPath(next)) { toast.success( translate( @@ -146,7 +180,8 @@ export function BrowserUseSetup({ const showStep2 = matchesSettingsSearch(searchQuery, [getBrowserUsePaneSearchEntries()[1]]) const showStep3 = matchesSettingsSearch(searchQuery, [getBrowserUsePaneSearchEntries()[2]]) const completedCount = [cliEnabled, skillDetected, cookiesImported].filter(Boolean).length - const step2Blocked = !cliEnabled && !skillDetected + const step2Blocked = + Boolean(activeSkillRuntime.installDisabledReason) || (!cliEnabled && !skillDetected) const step3Blocked = !cookiesImported && (!cliEnabled || !skillDetected) const sourceLabel = defaultProfile?.source @@ -239,17 +274,27 @@ export function BrowserUseSetup({ )} > + activeSkillRuntime.agentRuntime?.runtime === 'wsl' + ? window.api.cli.getWslInstallStatus( + getWslCliDistroRequest(activeSkillRuntime.agentRuntime) + ) + : window.api.cli.getInstallStatus() + } onBeforeOpenTerminal={async () => { useAppStore.getState().recordFeatureInteraction('agent-browser-setup') - await ensureOrcaCliAvailableForAgentSkillTerminal({ - onStatusChange: handleCliStatusChange - }) + await (activeSkillRuntime.agentRuntime?.runtime === 'wsl' + ? ensureWslCliAvailableForAgentSkillTerminal(activeSkillRuntime.agentRuntime) + : ensureOrcaCliAvailableForAgentSkillTerminal({ + onStatusChange: handleCliStatusChange + })) }} onRecheck={refreshSkill} /> diff --git a/src/renderer/src/components/settings/BrowserUseSkillStep.tsx b/src/renderer/src/components/settings/BrowserUseSkillStep.tsx index 207209dc138..4d38db66d43 100644 --- a/src/renderer/src/components/settings/BrowserUseSkillStep.tsx +++ b/src/renderer/src/components/settings/BrowserUseSkillStep.tsx @@ -9,7 +9,9 @@ type Props = { skillLoading: boolean skillError: string | null disabled?: boolean + terminalShellOverride?: string preInstallNotice?: ReactNode + getPrerequisiteStatus?: () => Promise>> onBeforeOpenTerminal?: () => void | Promise onRecheck: () => void | Promise } @@ -20,7 +22,9 @@ export function BrowserUseSkillStep({ skillLoading, skillError, disabled = false, + terminalShellOverride, preInstallNotice, + getPrerequisiteStatus, onBeforeOpenTerminal, onRecheck }: Props): React.JSX.Element { @@ -39,12 +43,14 @@ export function BrowserUseSkillStep({ terminalTitle="Browser Use setup" terminalAriaLabel="Browser Use skill install terminal" terminalWorktreeId="settings-browser-use-skill-terminal" + terminalShellOverride={terminalShellOverride} installed={skillDetected} loading={skillLoading} error={skillError} installDisabled={disabled} leading={} preInstallNotice={preInstallNotice} + getPrerequisiteStatus={getPrerequisiteStatus} onBeforeOpenTerminal={onBeforeOpenTerminal} onRecheck={onRecheck} /> diff --git a/src/renderer/src/components/settings/CliAgentSkillSetup.tsx b/src/renderer/src/components/settings/CliAgentSkillSetup.tsx index 19d98451b71..3241cb6dab9 100644 --- a/src/renderer/src/components/settings/CliAgentSkillSetup.tsx +++ b/src/renderer/src/components/settings/CliAgentSkillSetup.tsx @@ -18,7 +18,6 @@ import { import { AgentSkillSetupPanel } from './AgentSkillSetupPanel' import { buildSkillInstallCommandForRuntime, - CliSkillRuntimeControl, ensureWslCliAvailableForAgentSkillTerminal, getAgentSkillTerminalShellOverride, getSelectedAgentRuntime, @@ -30,7 +29,6 @@ import { translate } from '@/i18n/i18n' type CliAgentSkillSetupProps = { currentPlatform: string settings: GlobalSettings - updateSettings: (updates: Partial) => void wslSupportedPlatform: boolean wslAvailable: boolean wslCapabilitiesLoading: boolean @@ -40,7 +38,6 @@ type CliAgentSkillSetupProps = { export function CliAgentSkillSetup({ currentPlatform, settings, - updateSettings, wslSupportedPlatform, wslAvailable, wslCapabilitiesLoading, @@ -96,14 +93,6 @@ export function CliAgentSkillSetup({

- - ({ + props: null as null | { + getPrerequisiteStatus: () => Promise + onBeforeOpenTerminal: () => Promise + } +})) + +vi.mock('@/hooks/useInstalledAgentSkills', () => ({ + GLOBAL_AGENT_SKILL_SOURCE_KINDS: ['global'], + useInstalledAgentSkill: () => ({ + installed: false, + loading: false, + error: null, + refresh: vi.fn() + }) +})) + +vi.mock('./AgentSkillSetupPanel', () => ({ + AgentSkillSetupPanel: function AgentSkillSetupPanel(props: { + getPrerequisiteStatus: () => Promise + onBeforeOpenTerminal: () => Promise + }) { + capturedPanel.props = props + return
+ } +})) + +vi.mock('./CliRegistrationDialog', () => ({ + CliRegistrationDialog: function CliRegistrationDialog() { + return null + } +})) + +vi.mock('./WslCliRegistration', () => ({ + WslCliRegistration: function WslCliRegistration() { + return null + } +})) + +describe('CliSection project runtime defaults', () => { + it('passes the default project WSL distro to CLI skill prerequisite checks', async () => { + const getWslInstallStatus = vi + .fn() + .mockResolvedValue({ supported: true, state: 'installed', pathConfigured: true }) + vi.stubGlobal('window', { + api: { + cli: { + getInstallStatus: vi.fn(), + getWslInstallStatus, + installWsl: vi.fn() + }, + shell: { openPath: vi.fn() } + } + }) + + renderToStaticMarkup( + + ) + + await capturedPanel.props?.getPrerequisiteStatus() + await capturedPanel.props?.onBeforeOpenTerminal() + + expect(getWslInstallStatus).toHaveBeenCalledWith({ distro: 'Ubuntu' }) + expect(getWslInstallStatus).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/renderer/src/components/settings/CliSection.tsx b/src/renderer/src/components/settings/CliSection.tsx index 968840f536c..ad0336eda8a 100644 --- a/src/renderer/src/components/settings/CliSection.tsx +++ b/src/renderer/src/components/settings/CliSection.tsx @@ -25,10 +25,10 @@ import { AgentSkillSetupPanel } from './AgentSkillSetupPanel' import { CliRegistrationDialog } from './CliRegistrationDialog' import { buildSkillInstallCommandForRuntime, - CliSkillRuntimeControl, ensureWslCliAvailableForAgentSkillTerminal, getAgentSkillTerminalShellOverride, - getSelectedAgentRuntime + getSelectedAgentRuntime, + getWslCliDistroRequest } from './CliSkillRuntimeSetup' import { WslCliRegistration } from './WslCliRegistration' import { translate } from '@/i18n/i18n' @@ -36,7 +36,6 @@ import { translate } from '@/i18n/i18n' type CliSectionProps = { currentPlatform: string settings: GlobalSettings - updateSettings: (updates: Partial) => void wslSupportedPlatform?: boolean wslAvailable?: boolean wslCapabilitiesLoading?: boolean @@ -72,7 +71,6 @@ function getFallbackCommandName(platform: string): string { export function CliSection({ currentPlatform, settings, - updateSettings, wslSupportedPlatform = false, wslAvailable = false, wslCapabilitiesLoading = false @@ -112,9 +110,9 @@ export function CliSection({ const getCliSkillPrerequisiteStatus = useCallback( () => agentRuntime.runtime === 'wsl' - ? window.api.cli.getWslInstallStatus() + ? window.api.cli.getWslInstallStatus(getWslCliDistroRequest(agentRuntime)) : window.api.cli.getInstallStatus(), - [agentRuntime.runtime] + [agentRuntime] ) const handleStatusChange = useCallback( @@ -359,14 +357,6 @@ export function CliSection({

- - { await (agentRuntime.runtime === 'wsl' - ? ensureWslCliAvailableForAgentSkillTerminal() + ? ensureWslCliAvailableForAgentSkillTerminal(agentRuntime) : ensureOrcaCliAvailableForAgentSkillTerminal({ onStatusChange: handleStatusChange })) diff --git a/src/renderer/src/components/settings/CliSkillRuntimeSetup.test.tsx b/src/renderer/src/components/settings/CliSkillRuntimeSetup.test.tsx index e4103d51bd0..a0971f5538f 100644 --- a/src/renderer/src/components/settings/CliSkillRuntimeSetup.test.tsx +++ b/src/renderer/src/components/settings/CliSkillRuntimeSetup.test.tsx @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest' +import { getDefaultSettings } from '../../../../shared/constants' import { buildSkillInstallCommandForRuntime, + getSelectedAgentRuntime, getSkillDiscoveryTargetForRuntime } from './CliSkillRuntimeSetup' @@ -26,4 +28,38 @@ describe('CliSkillRuntimeSetup runtime helpers', () => { }) ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) }) + + it('uses the global project runtime default instead of stale WSL agent location', () => { + expect( + getSelectedAgentRuntime( + { + ...getDefaultSettings('/tmp'), + localAgentRuntime: 'wsl', + localAgentWslDistro: 'Debian', + terminalWindowsShell: 'wsl.exe', + terminalWindowsWslDistro: 'Debian', + localWindowsRuntimeDefault: { kind: 'windows-host' } + }, + true, + true, + false + ) + ).toMatchObject({ runtime: 'host' }) + }) + + it('uses the WSL global project runtime default instead of stale host agent location', () => { + expect( + getSelectedAgentRuntime( + { + ...getDefaultSettings('/tmp'), + localAgentRuntime: 'host', + terminalWindowsShell: 'powershell.exe', + localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } + }, + true, + true, + false + ) + ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu', label: 'WSL Ubuntu' }) + }) }) diff --git a/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx b/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx index 330cef2be47..6f3b44ff7ee 100644 --- a/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx +++ b/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx @@ -1,4 +1,8 @@ import type { GlobalSettings } from '../../../../shared/types' +import { + deriveGlobalWindowsRuntimeDefaultFromLegacySettings, + normalizeGlobalWindowsRuntimeDefault +} from '../../../../shared/project-execution-runtime' import { buildWslLoginShellCommand, escapeWslShCommandForWindows @@ -9,8 +13,6 @@ import { isOrcaCliAvailableOnPath, showOrcaCliRegistrationPromptToast } from '@/lib/agent-skill-cli-prerequisite' -import { Label } from '../ui/label' -import { SettingsSegmentedControl } from './SettingsFormControls' import { translate } from '@/i18n/i18n' export type LocalAgentRuntime = { @@ -29,15 +31,14 @@ export function getSelectedAgentRuntime( wslAvailable: boolean, wslCapabilitiesLoading: boolean ): LocalAgentRuntime { - const selectedRuntime = - settings.localAgentRuntime ?? (settings.terminalWindowsShell === 'wsl.exe' ? 'wsl' : 'host') - if ( - wslSupportedPlatform && - selectedRuntime === 'wsl' && - (wslAvailable || wslCapabilitiesLoading) - ) { - const selectedDistro = - settings.localAgentWslDistro?.trim() || settings.terminalWindowsWslDistro?.trim() || null + const defaultRuntime = normalizeGlobalWindowsRuntimeDefault( + settings.localWindowsRuntimeDefault ?? + deriveGlobalWindowsRuntimeDefaultFromLegacySettings(settings, { + wslAvailable: wslCapabilitiesLoading ? undefined : wslAvailable + }).defaultRuntime + ) + if (wslSupportedPlatform && defaultRuntime.kind === 'wsl') { + const selectedDistro = defaultRuntime.distro?.trim() || null return { runtime: 'wsl', wslDistro: selectedDistro, @@ -154,68 +155,3 @@ export async function ensureWslCliAvailableForAgentSkillTerminal( return null } } - -type CliSkillRuntimeControlProps = { - runtime: LocalAgentRuntime - updateSettings: (updates: Partial) => void - wslSupportedPlatform: boolean - wslAvailable: boolean - wslCapabilitiesLoading: boolean -} - -export function CliSkillRuntimeControl({ - runtime, - updateSettings, - wslSupportedPlatform, - wslAvailable, - wslCapabilitiesLoading -}: CliSkillRuntimeControlProps): React.JSX.Element | null { - if (!wslSupportedPlatform) { - return null - } - - return ( -
-
- -

- {runtime.runtime === 'wsl' && !wslAvailable && !wslCapabilitiesLoading - ? translate( - 'auto.components.settings.CliSkillRuntimeSetup.f00d6aa9b5', - 'WSL is not available on this machine.' - ) - : translate( - 'auto.components.settings.CliSkillRuntimeSetup.0c9f3cf9da', - 'Choose where Orca checks and installs global agent skills.' - )} -

-
-
- - updateSettings({ - localAgentRuntime: value, - localAgentWslDistro: null - }) - } - equalWidth - options={[ - { value: 'host', label: getHostRuntimeLabel() }, - { - value: 'wsl', - label: translate('auto.components.settings.CliSkillRuntimeSetup.04325573f8', 'WSL'), - disabled: wslCapabilitiesLoading || !wslAvailable - } - ]} - /> -
-
- ) -} diff --git a/src/renderer/src/components/settings/ComputerUsePane.tsx b/src/renderer/src/components/settings/ComputerUsePane.tsx index 8e43a9a7f09..f28c61597dc 100644 --- a/src/renderer/src/components/settings/ComputerUsePane.tsx +++ b/src/renderer/src/components/settings/ComputerUsePane.tsx @@ -1,34 +1,15 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' -import { - Accessibility, - Camera, - ExternalLink, - MonitorCog, - RefreshCw, - ShieldCheck -} from 'lucide-react' +import { Accessibility, Camera, ExternalLink, RefreshCw, ShieldCheck } from 'lucide-react' import { toast } from 'sonner' import type { ComputerUsePermissionId, ComputerUsePermissionState, ComputerUsePermissionStatus } from '../../../../shared/computer-use-permissions-types' -import { - COMPUTER_USE_SKILL_INSTALL_COMMAND, - COMPUTER_USE_SKILL_NAME -} from '@/lib/agent-feature-install-commands' -import { - AGENT_SKILL_CLI_PREREQUISITE_NOTICE, - ensureOrcaCliAvailableForAgentSkillTerminal -} from '@/lib/agent-skill-cli-prerequisite' -import { - GLOBAL_AGENT_SKILL_SOURCE_KINDS, - useInstalledAgentSkill -} from '@/hooks/useInstalledAgentSkills' import { useAppStore } from '@/store' import { Button } from '../ui/button' import { Badge } from '../ui/badge' -import { AgentSkillSetupPanel } from './AgentSkillSetupPanel' +import { ComputerUseSkillSetupPanel } from './ComputerUseSkillSetupPanel' import { translate } from '@/i18n/i18n' export { getComputerUsePaneSearchEntries } from './computer-use-search' @@ -90,14 +71,6 @@ export function ComputerUsePane(): React.JSX.Element { const permissionOperationSequence = useRef(0) const mountedRef = useRef(true) const [helperUnavailableReason, setHelperUnavailableReason] = useState(null) - const { - installed: computerUseSkillDetected, - loading: computerUseSkillLoading, - error: computerUseSkillError, - refresh: refreshComputerUseSkill - } = useInstalledAgentSkill(COMPUTER_USE_SKILL_NAME, { - sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS - }) const stateById = useMemo( () => new Map(states.map((state) => [state.id, state.status] as const)), @@ -382,30 +355,7 @@ export function ComputerUsePane(): React.JSX.Element { ) : null} - } - preInstallNotice={AGENT_SKILL_CLI_PREREQUISITE_NOTICE} - onBeforeOpenTerminal={async () => { - useAppStore.getState().recordFeatureInteraction('computer-use-setup') - await ensureOrcaCliAvailableForAgentSkillTerminal() - }} - onRecheck={refreshComputerUseSkill} - /> + ) } diff --git a/src/renderer/src/components/settings/ComputerUseSkillSetupPanel.tsx b/src/renderer/src/components/settings/ComputerUseSkillSetupPanel.tsx new file mode 100644 index 00000000000..5d11337b3b5 --- /dev/null +++ b/src/renderer/src/components/settings/ComputerUseSkillSetupPanel.tsx @@ -0,0 +1,77 @@ +import { MonitorCog } from 'lucide-react' +import { + COMPUTER_USE_SKILL_INSTALL_COMMAND, + COMPUTER_USE_SKILL_NAME +} from '@/lib/agent-feature-install-commands' +import { + AGENT_SKILL_CLI_PREREQUISITE_NOTICE, + ensureOrcaCliAvailableForAgentSkillTerminal +} from '@/lib/agent-skill-cli-prerequisite' +import { + GLOBAL_AGENT_SKILL_SOURCE_KINDS, + useInstalledAgentSkill +} from '@/hooks/useInstalledAgentSkills' +import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime' +import { useAppStore } from '@/store' +import { AgentSkillSetupPanel } from './AgentSkillSetupPanel' +import { + buildSkillInstallCommandForRuntime, + ensureWslCliAvailableForAgentSkillTerminal, + getWslCliDistroRequest +} from './CliSkillRuntimeSetup' +import { translate } from '@/i18n/i18n' + +export function ComputerUseSkillSetupPanel(): React.JSX.Element { + const activeSkillRuntime = useActiveProjectSkillRuntime() + const installCommand = + activeSkillRuntime.agentRuntime && !activeSkillRuntime.installDisabledReason + ? buildSkillInstallCommandForRuntime( + COMPUTER_USE_SKILL_INSTALL_COMMAND, + activeSkillRuntime.agentRuntime + ) + : COMPUTER_USE_SKILL_INSTALL_COMMAND + const { + installed: computerUseSkillDetected, + loading: computerUseSkillLoading, + error: computerUseSkillError, + refresh: refreshComputerUseSkill + } = useInstalledAgentSkill(COMPUTER_USE_SKILL_NAME, { + discoveryTarget: activeSkillRuntime.discoveryTarget, + sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS + }) + + return ( + } + preInstallNotice={AGENT_SKILL_CLI_PREREQUISITE_NOTICE} + getPrerequisiteStatus={() => + activeSkillRuntime.agentRuntime?.runtime === 'wsl' + ? window.api.cli.getWslInstallStatus( + getWslCliDistroRequest(activeSkillRuntime.agentRuntime) + ) + : window.api.cli.getInstallStatus() + } + onBeforeOpenTerminal={async () => { + useAppStore.getState().recordFeatureInteraction('computer-use-setup') + await (activeSkillRuntime.agentRuntime?.runtime === 'wsl' + ? ensureWslCliAvailableForAgentSkillTerminal(activeSkillRuntime.agentRuntime) + : ensureOrcaCliAvailableForAgentSkillTerminal()) + }} + onRecheck={refreshComputerUseSkill} + /> + ) +} diff --git a/src/renderer/src/components/settings/DefaultWindowsProjectRuntimeSetting.test.tsx b/src/renderer/src/components/settings/DefaultWindowsProjectRuntimeSetting.test.tsx new file mode 100644 index 00000000000..0c035992d3d --- /dev/null +++ b/src/renderer/src/components/settings/DefaultWindowsProjectRuntimeSetting.test.tsx @@ -0,0 +1,140 @@ +import React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../../../shared/constants' +import { SettingsSegmentedControl } from './SettingsFormControls' +import { DefaultWindowsProjectRuntimeSetting } from './DefaultWindowsProjectRuntimeSetting' + +type ReactElementLike = { + type: unknown + props: Record +} + +function visit(node: unknown, cb: (node: ReactElementLike) => void): void { + if (node == null || typeof node === 'string' || typeof node === 'number') { + return + } + if (Array.isArray(node)) { + node.forEach((entry) => visit(entry, cb)) + return + } + const element = node as ReactElementLike + cb(element) + if (element.props?.children) { + visit(element.props.children, cb) + } + if (element.props?.control) { + visit(element.props.control, cb) + } +} + +function findRuntimeControl(node: unknown): ReactElementLike { + let found: ReactElementLike | null = null + visit(node, (entry) => { + if ( + entry.type === SettingsSegmentedControl && + entry.props.ariaLabel === 'Default project runtime' + ) { + found = entry + } + }) + if (!found) { + throw new Error('default project runtime control not found') + } + return found +} + +function findDistroSelect(node: unknown): ReactElementLike { + let found: ReactElementLike | null = null + visit(node, (entry) => { + if (entry.props.value === 'Ubuntu-24.04' && typeof entry.props.onValueChange === 'function') { + found = entry + } + }) + if (!found) { + throw new Error('default distro select not found') + } + return found +} + +function renderSetting( + props: React.ComponentProps +): React.JSX.Element | null { + return DefaultWindowsProjectRuntimeSetting(props) +} + +describe('DefaultWindowsProjectRuntimeSetting', () => { + it('describes the Windows host global default', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('Default project runtime') + expect(markup).toContain('Projects inherit Windows unless a project overrides it.') + }) + + it('updates the global default to WSL using the first available distro', () => { + const updateSettings = vi.fn() + const element = renderSetting({ + settings: getDefaultSettings('/tmp'), + updateSettings, + wslSupportedPlatform: true, + wslAvailable: true, + wslDistros: ['Ubuntu-24.04'], + wslCapabilitiesLoading: false + }) + const control = findRuntimeControl(element) + const onChange = control.props.onChange as (value: 'windows-host' | 'wsl') => void + + onChange('wsl') + + expect(updateSettings).toHaveBeenCalledWith({ + localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu-24.04' } + }) + }) + + it('updates the selected WSL distro for the global default', () => { + const updateSettings = vi.fn() + const element = renderSetting({ + settings: { + ...getDefaultSettings('/tmp'), + localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu-24.04' } + }, + updateSettings, + wslSupportedPlatform: true, + wslAvailable: true, + wslDistros: ['Ubuntu-24.04', 'Debian'], + wslCapabilitiesLoading: false + }) + const select = findDistroSelect(element) + const onValueChange = select.props.onValueChange as (value: string) => void + + onValueChange('Debian') + + expect(updateSettings).toHaveBeenCalledWith({ + localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Debian' } + }) + }) + + it('does not render where local Windows WSL selection is unsupported', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toBe('') + }) +}) diff --git a/src/renderer/src/components/settings/DefaultWindowsProjectRuntimeSetting.tsx b/src/renderer/src/components/settings/DefaultWindowsProjectRuntimeSetting.tsx new file mode 100644 index 00000000000..827def4c2b2 --- /dev/null +++ b/src/renderer/src/components/settings/DefaultWindowsProjectRuntimeSetting.tsx @@ -0,0 +1,186 @@ +import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalWindowsRuntimeDefault } from '../../../../shared/project-execution-runtime' +import { normalizeGlobalWindowsRuntimeDefault } from '../../../../shared/project-execution-runtime' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' +import { SettingsRow, SettingsSegmentedControl } from './SettingsFormControls' +import { translate } from '@/i18n/i18n' + +type DefaultRuntimeSegment = GlobalWindowsRuntimeDefault['kind'] + +type DefaultWindowsProjectRuntimeSettingProps = { + settings: Pick + updateSettings: (updates: Partial) => void | Promise + wslSupportedPlatform: boolean + wslAvailable: boolean + wslDistros: string[] + wslCapabilitiesLoading: boolean +} + +const NO_DISTRO_VALUE = '__select_wsl_distro__' + +export function DefaultWindowsProjectRuntimeSetting({ + settings, + updateSettings, + wslSupportedPlatform, + wslAvailable, + wslDistros, + wslCapabilitiesLoading +}: DefaultWindowsProjectRuntimeSettingProps): React.JSX.Element | null { + if (!wslSupportedPlatform) { + return null + } + + const defaultRuntime = normalizeGlobalWindowsRuntimeDefault(settings.localWindowsRuntimeDefault) + const nextWslDistro = getNextDefaultWslDistro(defaultRuntime, wslDistros) + const distroOptions = getVisibleDistroOptions(defaultRuntime, wslDistros) + const handleRuntimeChange = (value: DefaultRuntimeSegment): void => { + if (value === 'windows-host') { + void updateSettings({ localWindowsRuntimeDefault: { kind: 'windows-host' } }) + return + } + if (nextWslDistro) { + void updateSettings({ + localWindowsRuntimeDefault: { kind: 'wsl', distro: nextWslDistro } + }) + } + } + + return ( +
+ + + ariaLabel={translate( + 'auto.components.settings.DefaultWindowsProjectRuntimeSetting.defaultRuntime', + 'Default project runtime' + )} + value={defaultRuntime.kind} + onChange={handleRuntimeChange} + equalWidth + options={[ + { + value: 'windows-host', + label: translate( + 'auto.components.settings.DefaultWindowsProjectRuntimeSetting.windows', + 'Windows' + ) + }, + { + value: 'wsl', + label: translate( + 'auto.components.settings.DefaultWindowsProjectRuntimeSetting.wsl', + 'WSL' + ), + disabled: wslCapabilitiesLoading || !wslAvailable || !nextWslDistro + } + ]} + /> + {defaultRuntime.kind === 'wsl' ? ( + + ) : null} + + } + /> +
+ ) +} + +function getNextDefaultWslDistro( + defaultRuntime: GlobalWindowsRuntimeDefault, + wslDistros: readonly string[] +): string | null { + if (defaultRuntime.kind === 'wsl' && defaultRuntime.distro?.trim()) { + return defaultRuntime.distro.trim() + } + return wslDistros.find((distro) => distro.trim().length > 0) ?? null +} + +function getVisibleDistroOptions( + defaultRuntime: GlobalWindowsRuntimeDefault, + wslDistros: readonly string[] +): string[] { + const options = [...wslDistros] + if ( + defaultRuntime.kind === 'wsl' && + defaultRuntime.distro && + !options.includes(defaultRuntime.distro) + ) { + return [defaultRuntime.distro, ...options] + } + return options +} + +function getDefaultRuntimeDescription( + defaultRuntime: GlobalWindowsRuntimeDefault, + wslAvailable: boolean, + wslCapabilitiesLoading: boolean +): string { + if (defaultRuntime.kind === 'windows-host') { + return translate( + 'auto.components.settings.DefaultWindowsProjectRuntimeSetting.windowsDescription', + 'Projects inherit Windows unless a project overrides it.' + ) + } + if (!wslAvailable && !wslCapabilitiesLoading) { + return translate( + 'auto.components.settings.DefaultWindowsProjectRuntimeSetting.wslUnavailable', + 'WSL is not available. Projects that inherit WSL will need repair.' + ) + } + if (!defaultRuntime.distro) { + return translate( + 'auto.components.settings.DefaultWindowsProjectRuntimeSetting.distroRequired', + 'Choose a WSL distro before projects can inherit WSL.' + ) + } + return translate( + 'auto.components.settings.DefaultWindowsProjectRuntimeSetting.wslDescription', + 'Projects inherit {{value0}} via WSL unless a project overrides it.', + { value0: defaultRuntime.distro } + ) +} diff --git a/src/renderer/src/components/settings/GeneralPane.test.ts b/src/renderer/src/components/settings/GeneralPane.test.ts index 0b437a9a52f..6a9badc0e60 100644 --- a/src/renderer/src/components/settings/GeneralPane.test.ts +++ b/src/renderer/src/components/settings/GeneralPane.test.ts @@ -4,12 +4,14 @@ import { createHttpProxyBypassRulesDraftState, createHttpProxyUrlDraftState, getDesktopPlatformFromUserAgent, + getGeneralPaneSearchEntries, setHttpProxyUrlDraftErrorState, shouldCommitOpenInApplicationsDraft, updateAutoSaveDelayDraftState, updateHttpProxyBypassRulesDraftState, updateHttpProxyUrlDraftState } from './GeneralPane' +import { matchesSettingsSearch } from './settings-search' describe('GeneralPane auto-save delay drafts', () => { it('keeps a committed draft tied to the current persisted source while settings save is pending', () => { @@ -130,3 +132,21 @@ describe('GeneralPane desktop platform detection', () => { expect(getDesktopPlatformFromUserAgent('Mozilla/5.0 (X11; Linux x86_64)')).toBe('other') }) }) + +describe('GeneralPane search entries', () => { + it('includes the default project runtime setting', () => { + const entries = getGeneralPaneSearchEntries() + + expect(matchesSettingsSearch('default project runtime', entries)).toBe(true) + expect(matchesSettingsSearch('windows host', entries)).toBe(true) + expect(matchesSettingsSearch('wsl', entries)).toBe(true) + }) + + it('omits the default project runtime setting when Windows runtimes are unsupported', () => { + const entries = getGeneralPaneSearchEntries({ includeProjectRuntime: false }) + + expect(matchesSettingsSearch('default project runtime', entries)).toBe(false) + expect(matchesSettingsSearch('windows host', entries)).toBe(false) + expect(matchesSettingsSearch('wsl', entries)).toBe(false) + }) +}) diff --git a/src/renderer/src/components/settings/GeneralPane.tsx b/src/renderer/src/components/settings/GeneralPane.tsx index f6e5a3d6279..ede32815049 100644 --- a/src/renderer/src/components/settings/GeneralPane.tsx +++ b/src/renderer/src/components/settings/GeneralPane.tsx @@ -20,10 +20,12 @@ import { getGeneralUpdateSearchEntries, getGeneralWorkspaceSearchEntries } from './general-search' +import { getGeneralProjectRuntimeSearchEntries } from './general-project-runtime-search' import { RecentTabOrderControl } from './RecentTabOrderControl' import { matchesSettingsSearch } from './settings-search' import { SettingsSubsectionHeader } from './SettingsFormControls' import { translate } from '@/i18n/i18n' +import { DefaultWindowsProjectRuntimeSetting } from './DefaultWindowsProjectRuntimeSetting' export { createAutoSaveDelayDraftState, @@ -53,11 +55,14 @@ export function getDesktopPlatformFromUserAgent(userAgent: string): 'darwin' | ' export { getGeneralPaneSearchEntries } +const EMPTY_WSL_DISTROS: string[] = [] + type GeneralPaneProps = { settings: GlobalSettings updateSettings: (updates: Partial) => void wslSupportedPlatform?: boolean wslAvailable?: boolean + wslDistros?: string[] wslCapabilitiesLoading?: boolean } @@ -66,9 +71,13 @@ export function GeneralPane({ updateSettings, wslSupportedPlatform, wslAvailable, + wslDistros = EMPTY_WSL_DISTROS, wslCapabilitiesLoading }: GeneralPaneProps): React.JSX.Element { const searchQuery = useAppStore((s) => s.settingsSearchQuery) + const projectRuntimeSearchEntries = wslSupportedPlatform + ? getGeneralProjectRuntimeSearchEntries() + : [] const visibleSections = [ matchesSettingsSearch(searchQuery, getGeneralNavigationSearchEntries()) ? ( @@ -94,6 +103,28 @@ export function GeneralPane({ updateSettings={updateSettings} /> ) : null, + matchesSettingsSearch(searchQuery, projectRuntimeSearchEntries) ? ( +
+ + +
+ ) : null, matchesSettingsSearch(searchQuery, getGeneralNetworkSearchEntries()) ? ( (null) const [skillPromptOpen, setSkillPromptOpen] = useState(false) + const activeSkillRuntime = useActiveProjectSkillRuntime() + const orchestrationInstallCommand = + activeSkillRuntime.agentRuntime && !activeSkillRuntime.installDisabledReason + ? buildSkillInstallCommandForRuntime( + ORCHESTRATION_SKILL_INSTALL_COMMAND, + activeSkillRuntime.agentRuntime + ) + : ORCHESTRATION_SKILL_INSTALL_COMMAND const { installed: orchestrationSkillDetected, @@ -42,6 +56,7 @@ export function OrchestrationPane(): React.JSX.Element { skills: discoveredSkills, refresh: refreshOrchestrationSkill } = useInstalledAgentSkill(ORCHESTRATION_SKILL_NAME, { + discoveryTarget: activeSkillRuntime.discoveryTarget, sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS }) @@ -71,36 +86,49 @@ export function OrchestrationPane(): React.JSX.Element { 'auto.components.settings.OrchestrationPane.9bedd2a6e5', 'Enables agents to hand off context and coordinate work through Orca.' )} - command={ORCHESTRATION_SKILL_INSTALL_COMMAND} + command={orchestrationInstallCommand} terminalTitle="Orchestration setup" terminalAriaLabel="Orchestration skill install terminal" terminalWorktreeId="settings-orchestration-skill-terminal" + terminalShellOverride={activeSkillRuntime.terminalShellOverride} installed={orchestrationSkillDetected} loading={orchestrationSkillLoading} - error={orchestrationSkillError} + error={activeSkillRuntime.installDisabledReason ?? orchestrationSkillError} + installDisabled={Boolean(activeSkillRuntime.installDisabledReason)} icon={} preInstallNotice={AGENT_SKILL_CLI_PREREQUISITE_NOTICE} + getPrerequisiteStatus={() => + activeSkillRuntime.agentRuntime?.runtime === 'wsl' + ? window.api.cli.getWslInstallStatus( + getWslCliDistroRequest(activeSkillRuntime.agentRuntime) + ) + : window.api.cli.getInstallStatus() + } onBeforeOpenTerminal={async () => { useAppStore.getState().recordFeatureInteraction('agent-orchestration-setup') - await ensureOrcaCliAvailableForAgentSkillTerminal() + await (activeSkillRuntime.agentRuntime?.runtime === 'wsl' + ? ensureWslCliAvailableForAgentSkillTerminal(activeSkillRuntime.agentRuntime) + : ensureOrcaCliAvailableForAgentSkillTerminal()) }} actionHint={ -

- {translate( - 'auto.components.settings.OrchestrationPane.832f1f3ee6', - 'Prefer your own terminal?' - )}{' '} - -

+ 'auto.components.settings.OrchestrationPane.832f1f3ee6', + 'Prefer your own terminal?' + )}{' '} + +

+ ) } footer={ diff --git a/src/renderer/src/components/settings/OrchestrationSetupCard.tsx b/src/renderer/src/components/settings/OrchestrationSetupCard.tsx index 2b9c8c4c054..b25ac31d50f 100644 --- a/src/renderer/src/components/settings/OrchestrationSetupCard.tsx +++ b/src/renderer/src/components/settings/OrchestrationSetupCard.tsx @@ -5,7 +5,13 @@ import { } from '@/lib/agent-skill-cli-prerequisite' import { ORCHESTRATION_SKILL_INSTALL_COMMAND } from '@/lib/orchestration-install-command' import type { InstalledAgentSkillState } from '@/hooks/useInstalledAgentSkills' +import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime' import { AgentSkillSetupPanel } from './AgentSkillSetupPanel' +import { + buildSkillInstallCommandForRuntime, + ensureWslCliAvailableForAgentSkillTerminal, + getWslCliDistroRequest +} from './CliSkillRuntimeSetup' import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' @@ -15,6 +21,14 @@ export function OrchestrationSetupCard(props: { skill: InstalledAgentSkillState }): JSX.Element { const { compact, terminalHeightPx, skill } = props + const activeSkillRuntime = useActiveProjectSkillRuntime() + const installCommand = + activeSkillRuntime.agentRuntime && !activeSkillRuntime.installDisabledReason + ? buildSkillInstallCommandForRuntime( + ORCHESTRATION_SKILL_INSTALL_COMMAND, + activeSkillRuntime.agentRuntime + ) + : ORCHESTRATION_SKILL_INSTALL_COMMAND const setupPanel = ( + activeSkillRuntime.agentRuntime?.runtime === 'wsl' + ? window.api.cli.getWslInstallStatus( + getWslCliDistroRequest(activeSkillRuntime.agentRuntime) + ) + : window.api.cli.getInstallStatus() + } onBeforeOpenTerminal={async () => { useAppStore.getState().recordFeatureInteraction('agent-orchestration-setup') - await ensureOrcaCliAvailableForAgentSkillTerminal() + await (activeSkillRuntime.agentRuntime?.runtime === 'wsl' + ? ensureWslCliAvailableForAgentSkillTerminal(activeSkillRuntime.agentRuntime) + : ensureOrcaCliAvailableForAgentSkillTerminal()) }} onRecheck={skill.refresh} /> diff --git a/src/renderer/src/components/settings/ProjectWindowsRuntimeSetting.test.tsx b/src/renderer/src/components/settings/ProjectWindowsRuntimeSetting.test.tsx new file mode 100644 index 00000000000..94ea998bb8e --- /dev/null +++ b/src/renderer/src/components/settings/ProjectWindowsRuntimeSetting.test.tsx @@ -0,0 +1,234 @@ +// @vitest-environment happy-dom + +import React, { act } from 'react' +import { createRoot } from 'react-dom/client' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../../../shared/constants' +import type { Project } from '../../../../shared/types' +import { ProjectWindowsRuntimeSetting } from './ProjectWindowsRuntimeSetting' + +const project: Project = { + id: 'project-1', + displayName: 'Example Project', + badgeColor: '#000000', + sourceRepoIds: ['repo-1'], + createdAt: 1, + updatedAt: 1 +} + +function renderClient(props: React.ComponentProps): { + container: HTMLDivElement + root: ReturnType +} { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + act(() => { + root.render() + }) + return { container, root } +} + +function clickButton(container: HTMLElement, label: string): void { + const button = Array.from(container.querySelectorAll('button')).find( + (entry) => entry.textContent?.trim() === label + ) + expect(button).toBeTruthy() + act(() => { + button?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) +} + +function cleanupClient(container: HTMLElement, root: ReturnType): void { + act(() => { + root.unmount() + }) + container.remove() +} + +describe('ProjectWindowsRuntimeSetting', () => { + it('describes the inherited global WSL runtime for a local Windows project', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('Project runtime') + expect(markup).toContain('No project override. General settings select Ubuntu-24.04 via WSL.') + expect(markup).toContain('Existing terminals keep their current runtime.') + expect(markup).toContain('Default (WSL)') + expect(markup).toContain('Windows') + expect(markup).toContain('WSL') + }) + + it('persists runtime override changes through the project update path', () => { + const updateProject = vi.fn() + const { container, root } = renderClient({ + project, + settings: getDefaultSettings('/tmp'), + isLocalWindowsProject: true, + wslAvailable: true, + wslDistros: ['Ubuntu-24.04'], + wslCapabilitiesLoading: false, + updateProject + }) + + try { + clickButton(container, 'Windows') + expect(updateProject).toHaveBeenCalledWith('project-1', { + localWindowsRuntimePreference: { kind: 'windows-host' } + }) + + clickButton(container, 'WSL') + expect(updateProject).toHaveBeenCalledWith('project-1', { + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu-24.04' } + }) + + act(() => { + root.render( + + ) + }) + clickButton(container, 'Default (Windows)') + expect(updateProject).toHaveBeenCalledWith('project-1', { + localWindowsRuntimePreference: undefined + }) + } finally { + cleanupClient(container, root) + } + }) + + it('shows the selected distro for explicit WSL project overrides', () => { + const updateProject = vi.fn() + const { container, root } = renderClient({ + project: { + ...project, + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu-24.04' } + }, + settings: getDefaultSettings('/tmp'), + isLocalWindowsProject: true, + wslAvailable: true, + wslDistros: ['Ubuntu-24.04', 'Debian'], + wslCapabilitiesLoading: false, + updateProject + }) + + try { + expect(container.textContent).toContain('Ubuntu-24.04') + } finally { + cleanupClient(container, root) + } + }) + + it('requires apply before switching runtime when live project sessions exist', () => { + const updateProject = vi.fn() + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + + try { + act(() => { + root.render( + + ) + }) + + const wslButton = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === 'WSL' + ) + expect(wslButton).toBeTruthy() + + act(() => { + wslButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(updateProject).not.toHaveBeenCalled() + expect(container.textContent).toContain('Runtime change pending') + + const applyButton = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes('Apply runtime change') + ) + expect(applyButton).toBeTruthy() + + act(() => { + applyButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(updateProject).toHaveBeenCalledWith('project-1', { + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu-24.04' } + }) + } finally { + act(() => { + root.unmount() + }) + container.remove() + } + }) + + it('shows repair copy instead of silently falling back when a selected WSL distro is missing', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('Ubuntu-24.04 is not installed in WSL.') + expect(markup).toContain('Choose an installed distro or switch this project to Windows.') + }) + + it('does not render for remote or non-Windows-owned projects', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toBe('') + }) +}) diff --git a/src/renderer/src/components/settings/ProjectWindowsRuntimeSetting.tsx b/src/renderer/src/components/settings/ProjectWindowsRuntimeSetting.tsx new file mode 100644 index 00000000000..84beb4296e5 --- /dev/null +++ b/src/renderer/src/components/settings/ProjectWindowsRuntimeSetting.tsx @@ -0,0 +1,375 @@ +import type { GlobalSettings, Project, ProjectUpdateArgs } from '../../../../shared/types' +import type { LocalWindowsRuntimePreference } from '../../../../shared/project-execution-runtime' +import { + normalizeProjectRuntimePreference, + resolveProjectExecutionRuntime +} from '../../../../shared/project-execution-runtime' +import { useState } from 'react' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' +import { Button } from '../ui/button' +import { SettingsRow, SettingsSegmentedControl } from './SettingsFormControls' +import type { ProjectRuntimeSessionSummary } from './repository-runtime-session-summary' +import { translate } from '@/i18n/i18n' + +type ProjectRuntimeSegment = LocalWindowsRuntimePreference['kind'] + +type ProjectWindowsRuntimeSettingProps = { + project: Project | null + settings: Pick + isLocalWindowsProject: boolean + wslAvailable: boolean + wslDistros: string[] + wslCapabilitiesLoading: boolean + runtimeSessionSummary?: ProjectRuntimeSessionSummary + updateProject: ( + projectId: string, + updates: ProjectUpdateArgs['updates'] + ) => void | Promise +} + +export function ProjectWindowsRuntimeSetting({ + project, + settings, + isLocalWindowsProject, + wslAvailable, + wslDistros, + wslCapabilitiesLoading, + runtimeSessionSummary, + updateProject +}: ProjectWindowsRuntimeSettingProps): React.JSX.Element | null { + const [pendingPreference, setPendingPreference] = useState( + null + ) + + if (!project || !isLocalWindowsProject) { + return null + } + + const preference = normalizeProjectRuntimePreference(project.localWindowsRuntimePreference) + const selectedPreference = pendingPreference ?? preference + const nextWslDistro = getNextProjectWslDistro(selectedPreference, settings, wslDistros) + const resolution = resolveProjectExecutionRuntime({ + appPlatform: 'win32', + projectId: project.id, + projectRuntimePreference: preference, + globalWindowsRuntimeDefault: settings.localWindowsRuntimeDefault, + wslAvailable: wslCapabilitiesLoading ? undefined : wslAvailable, + availableWslDistros: wslCapabilitiesLoading ? null : wslDistros + }) + const isWslSelected = selectedPreference.kind === 'wsl' + const distroOptions = getVisibleDistroOptions(selectedPreference, wslDistros) + const runtimeSessionWarning = getRuntimeSessionWarning(runtimeSessionSummary) + const hasRuntimeSessions = hasActiveRuntimeSessions(runtimeSessionSummary) + const hasPendingPreference = pendingPreference !== null + const defaultRuntimeLabel = getDefaultRuntimeLabel(settings) + const commitRuntimePreference = (nextPreference: LocalWindowsRuntimePreference): void => { + setPendingPreference(null) + if (nextPreference.kind === 'inherit-global') { + void updateProject(project.id, { localWindowsRuntimePreference: undefined }) + return + } + if (nextPreference.kind === 'windows-host') { + void updateProject(project.id, { + localWindowsRuntimePreference: { kind: 'windows-host' } + }) + return + } + void updateProject(project.id, { + localWindowsRuntimePreference: { kind: 'wsl', distro: nextPreference.distro } + }) + } + const requestRuntimePreference = (nextPreference: LocalWindowsRuntimePreference): void => { + if (sameRuntimePreference(nextPreference, preference)) { + setPendingPreference(null) + return + } + if (hasRuntimeSessions) { + setPendingPreference(nextPreference) + return + } + commitRuntimePreference(nextPreference) + } + const handleRuntimeChange = (value: ProjectRuntimeSegment): void => { + if (value === 'inherit-global') { + requestRuntimePreference({ kind: 'inherit-global' }) + return + } + if (value === 'windows-host') { + requestRuntimePreference({ kind: 'windows-host' }) + return + } + if (nextWslDistro) { + requestRuntimePreference({ kind: 'wsl', distro: nextWslDistro }) + } + } + const handleDistroChange = (distro: string): void => { + requestRuntimePreference({ kind: 'wsl', distro }) + } + + return ( +
+ + + ariaLabel={translate( + 'auto.components.settings.ProjectWindowsRuntimeSetting.projectRuntime', + 'Project runtime' + )} + value={selectedPreference.kind} + onChange={handleRuntimeChange} + options={[ + { + value: 'inherit-global', + label: {defaultRuntimeLabel} + }, + { + value: 'windows-host', + label: translate( + 'auto.components.settings.ProjectWindowsRuntimeSetting.windows', + 'Windows' + ) + }, + { + value: 'wsl', + label: translate( + 'auto.components.settings.ProjectWindowsRuntimeSetting.wsl', + 'WSL' + ), + disabled: wslCapabilitiesLoading || !wslAvailable || !nextWslDistro + } + ]} + /> + {isWslSelected ? ( + + ) : null} + + } + /> +

+ {translate( + 'auto.components.settings.ProjectWindowsRuntimeSetting.runtimeChangeHelp', + 'Runtime changes apply to new terminals, agent checks, and skill discovery for this project. Existing terminals keep their current runtime.' + )} +

+ {runtimeSessionWarning ? ( +

{runtimeSessionWarning}

+ ) : null} + {hasPendingPreference ? ( +
+

+ {translate( + 'auto.components.settings.ProjectWindowsRuntimeSetting.pendingRuntimeChange', + 'Runtime change pending. New project work will use the selected runtime after you apply.' + )} +

+ + +
+ ) : null} +
+ ) +} + +function hasActiveRuntimeSessions(summary?: ProjectRuntimeSessionSummary): boolean { + return (summary?.liveTerminalCount ?? 0) > 0 || (summary?.activeTaskCount ?? 0) > 0 +} + +function sameRuntimePreference( + left: LocalWindowsRuntimePreference, + right: LocalWindowsRuntimePreference +): boolean { + if (left.kind !== right.kind) { + return false + } + return left.kind !== 'wsl' || left.distro === (right.kind === 'wsl' ? right.distro : null) +} + +function joinRuntimeSessionParts(parts: string[]): string { + if (parts.length <= 1) { + return parts[0] ?? '' + } + return translate( + 'auto.components.settings.ProjectWindowsRuntimeSetting.runtimeSessionJoin', + '{{value0}} and {{value1}}', + { value0: parts.slice(0, -1).join(', '), value1: parts.at(-1) } + ) +} + +function getLiveTerminalCountLabel(count: number): string { + return translate( + count === 1 + ? 'auto.components.settings.ProjectWindowsRuntimeSetting.liveTerminalSingular' + : 'auto.components.settings.ProjectWindowsRuntimeSetting.liveTerminalPlural', + count === 1 ? '{{count}} live terminal' : '{{count}} live terminals', + { count } + ) +} + +function getActiveTaskCountLabel(count: number): string { + return translate( + count === 1 + ? 'auto.components.settings.ProjectWindowsRuntimeSetting.activeTaskSingular' + : 'auto.components.settings.ProjectWindowsRuntimeSetting.activeTaskPlural', + count === 1 ? '{{count}} active task' : '{{count}} active tasks', + { count } + ) +} + +function getRuntimeSessionWarning(summary?: ProjectRuntimeSessionSummary): string | null { + const liveTerminalCount = summary?.liveTerminalCount ?? 0 + const activeTaskCount = summary?.activeTaskCount ?? 0 + if (liveTerminalCount === 0 && activeTaskCount === 0) { + return null + } + + const parts = [ + liveTerminalCount > 0 ? getLiveTerminalCountLabel(liveTerminalCount) : '', + activeTaskCount > 0 ? getActiveTaskCountLabel(activeTaskCount) : '' + ].filter((part) => part.length > 0) + + return translate( + 'auto.components.settings.ProjectWindowsRuntimeSetting.runtimeSessionWarning', + '{{value0}} will keep running in the current runtime. Let tasks finish or restart terminals before continuing.', + { value0: joinRuntimeSessionParts(parts) } + ) +} + +function getNextProjectWslDistro( + preference: LocalWindowsRuntimePreference, + settings: Pick, + wslDistros: readonly string[] +): string | null { + if (preference.kind === 'wsl') { + return preference.distro + } + const globalDistro = + settings.localWindowsRuntimeDefault.kind === 'wsl' + ? settings.localWindowsRuntimeDefault.distro + : null + if (globalDistro?.trim()) { + return globalDistro.trim() + } + return wslDistros.find((distro) => distro.trim().length > 0) ?? null +} + +function getVisibleDistroOptions( + preference: LocalWindowsRuntimePreference, + wslDistros: readonly string[] +): string[] { + const options = [...wslDistros] + if (preference.kind === 'wsl' && !options.includes(preference.distro)) { + return [preference.distro, ...options] + } + return options +} + +function getDefaultRuntimeLabel( + settings: Pick +): string { + const runtimeLabel = + settings.localWindowsRuntimeDefault.kind === 'wsl' + ? translate('auto.components.settings.ProjectWindowsRuntimeSetting.wsl', 'WSL') + : translate('auto.components.settings.ProjectWindowsRuntimeSetting.windows', 'Windows') + + return translate( + 'auto.components.settings.ProjectWindowsRuntimeSetting.defaultRuntime', + 'Default ({{value0}})', + { value0: runtimeLabel } + ) +} + +function getProjectRuntimeDescription( + resolution: ReturnType +): string { + if (resolution.status === 'repair-required') { + if (resolution.repair.reason === 'wsl-unavailable') { + return translate( + 'auto.components.settings.ProjectWindowsRuntimeSetting.wslUnavailable', + 'WSL is not available. Switch this project to Windows or repair WSL.' + ) + } + if (resolution.repair.reason === 'wsl-distro-missing') { + return translate( + 'auto.components.settings.ProjectWindowsRuntimeSetting.distroMissing', + '{{value0}} is not installed in WSL. Choose an installed distro or switch this project to Windows.', + { value0: resolution.repair.preferredRuntime.distro ?? 'WSL' } + ) + } + return translate( + 'auto.components.settings.ProjectWindowsRuntimeSetting.distroRequired', + 'Choose a WSL distro or switch this project to Windows.' + ) + } + + if (resolution.runtime.kind === 'wsl') { + return resolution.runtime.reason === 'global-default' + ? translate( + 'auto.components.settings.ProjectWindowsRuntimeSetting.inheritedWsl', + 'No project override. General settings select {{value0}} via WSL.', + { value0: resolution.runtime.distro } + ) + : translate( + 'auto.components.settings.ProjectWindowsRuntimeSetting.projectWsl', + 'This project runs in {{value0}} via WSL.', + { value0: resolution.runtime.distro } + ) + } + + return resolution.runtime.reason === 'global-default' + ? translate( + 'auto.components.settings.ProjectWindowsRuntimeSetting.inheritedWindows', + 'No project override. General settings select Windows.' + ) + : translate( + 'auto.components.settings.ProjectWindowsRuntimeSetting.projectWindows', + 'This project runs on Windows.' + ) +} diff --git a/src/renderer/src/components/settings/RepositoryPane.test.ts b/src/renderer/src/components/settings/RepositoryPane.test.ts index f83ce0583a6..cd900ebf615 100644 --- a/src/renderer/src/components/settings/RepositoryPane.test.ts +++ b/src/renderer/src/components/settings/RepositoryPane.test.ts @@ -1,7 +1,12 @@ -import React from 'react' +// @vitest-environment happy-dom + +import React, { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' import { renderToStaticMarkup } from 'react-dom/server' -import { describe, expect, it, vi } from 'vitest' -import type { Repo } from '../../../../shared/types' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../../../shared/constants' +import type { Project, Repo, TerminalTab } from '../../../../shared/types' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import { useAppStore } from '../../store' import { getRepositoryPaneSearchEntries, @@ -11,6 +16,9 @@ import { import { matchesSettingsSearch } from './settings-search' import { TooltipProvider } from '../ui/tooltip' +let container: HTMLDivElement +let root: Root + const repo: Repo = { id: 'repo-1', path: '/tmp/repo', @@ -20,21 +28,94 @@ const repo: Repo = { kind: 'git' } +const project: Project = { + id: 'project-1', + displayName: 'Example Project', + badgeColor: '#000000', + sourceRepoIds: ['repo-1'], + createdAt: 1, + updatedAt: 1 +} + +function terminalTab(id: string, worktreeId: string): TerminalTab { + return { + id, + ptyId: null, + worktreeId, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +function activeAgent(paneKey: string, worktreeId: string): AgentStatusEntry { + return { + state: 'working', + prompt: 'Implement runtime switch', + updatedAt: 1, + stateStartedAt: 1, + agentType: 'codex', + paneKey, + tabId: paneKey.split(':')[0], + worktreeId, + stateHistory: [] + } +} + +beforeEach(() => { + useAppStore.setState(useAppStore.getInitialState(), true) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + useAppStore.setState(useAppStore.getInitialState(), true) +}) + describe('RepositoryPane search entries', () => { it('keeps renamed hook sections reachable through settings search', () => { - const entries = getRepositoryPaneSearchEntries(repo) + const entries = getRepositoryPaneSearchEntries(repo, { isLocalWindowsProject: true }) expect(matchesSettingsSearch('setup script', entries)).toBe(true) expect(matchesSettingsSearch('archive script', entries)).toBe(true) expect(matchesSettingsSearch('setup command', entries)).toBe(true) expect(matchesSettingsSearch('archive command', entries)).toBe(true) expect(matchesSettingsSearch('advanced', entries)).toBe(true) + expect(matchesSettingsSearch('runtime', entries)).toBe(true) + expect(matchesSettingsSearch('wsl', entries)).toBe(true) + expect(matchesSettingsSearch('windows host', entries)).toBe(true) expect(matchesSettingsSearch('command source', entries)).toBe(true) expect(matchesSettingsSearch('local settings scripts', entries)).toBe(true) expect(matchesSettingsSearch('../worktrees', entries)).toBe(true) expect(matchesSettingsSearch('worktree path', entries)).toBe(true) }) + it('omits project runtime search for remote or unsupported repos', () => { + expect(matchesSettingsSearch('project runtime', getRepositoryPaneSearchEntries(repo))).toBe( + false + ) + expect( + matchesSettingsSearch( + 'project runtime', + getRepositoryPaneSearchEntries( + { + ...repo, + connectionId: 'builder', + executionHostId: 'ssh:builder' + }, + { windowsRuntimeSupported: true } + ) + ) + ).toBe(false) + }) + it('matches project identity searches on display name and path only', () => { expect(matchesRepositoryIdentitySearch('example repo', repo)).toBe(true) expect(matchesRepositoryIdentitySearch('/tmp/repo', repo)).toBe(true) @@ -75,4 +156,51 @@ describe('RepositoryPane search entries', () => { }) } }) + + it('warns about live terminals and active tasks before project runtime changes', () => { + const worktreeId = 'repo-1::/tmp/repo' + useAppStore.setState({ + settingsSearchQuery: 'Example Repo', + settingsSearchInputQuery: 'Example Repo', + settings: getDefaultSettings('/tmp'), + tabsByWorktree: { + [worktreeId]: [terminalTab('tab-1', worktreeId), terminalTab('tab-2', worktreeId)] + }, + ptyIdsByTabId: { + 'tab-1': ['pty-1'], + 'tab-2': ['pty-2'] + }, + agentStatusByPaneKey: { + 'tab-2:0': activeAgent('tab-2:0', worktreeId) + } + }) + + act(() => { + root.render( + React.createElement( + TooltipProvider, + null, + React.createElement(RepositoryPane, { + repo, + project, + yamlHooks: null, + hasHooksFile: false, + hooksInspectionReady: true, + mayNeedUpdate: false, + updateRepo: vi.fn(), + removeProject: vi.fn(), + isLocalWindowsProject: true, + wslAvailable: true, + wslDistros: ['Ubuntu-24.04'], + wslCapabilitiesLoading: false, + updateProject: vi.fn() + }) + ) + ) + }) + + expect(container.textContent).toContain('2 live terminals') + expect(container.textContent).toContain('1 active task') + expect(container.textContent).toContain('finish or restart') + }) }) diff --git a/src/renderer/src/components/settings/RepositoryPane.tsx b/src/renderer/src/components/settings/RepositoryPane.tsx index f9b5d204535..e2dac4fea38 100644 --- a/src/renderer/src/components/settings/RepositoryPane.tsx +++ b/src/renderer/src/components/settings/RepositoryPane.tsx @@ -1,19 +1,25 @@ import { useCallback, useRef, useState } from 'react' -import type { OrcaHooks, Repo, RepoHookSettings } from '../../../../shared/types' +import type { + OrcaHooks, + Project, + ProjectUpdateArgs, + Repo, + RepoHookSettings +} from '../../../../shared/types' import { getRepoKindLabel, isFolderRepo } from '../../../../shared/repo-kind' import { Button } from '../ui/button' import { Label } from '../ui/label' import { Separator } from '../ui/separator' import { Trash2 } from 'lucide-react' +import { useShallow } from 'zustand/react/shallow' import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' -import { BaseRefPicker } from './BaseRefPicker' import { RepositoryHooksSection } from './RepositoryHooksSection' import { McpConfigSection } from './McpConfigSection' import { WorktreeSymlinksSection } from './WorktreeSymlinksSection' import { SparsePresetSettingsSection } from './SparsePresetSettingsSection' import { RepositorySourceControlAiSection } from './RepositorySourceControlAiSection' import { SearchableSetting } from './SearchableSetting' -import { matchesSettingsSearch, normalizeSettingsSearchQuery } from './settings-search' +import { matchesSettingsSearch } from './settings-search' import { useAppStore } from '../../store' import { getRepositoryIconSectionId } from './repository-settings-targets' import { RepositoryIconPicker } from './RepositoryIconPicker' @@ -22,12 +28,19 @@ import { RepositoryHostSetupsSection } from './RepositoryHostSetupsSection' import { RepoSettingsDraftInput } from './RepositorySettingsDraftInput' import { RepositoryForkSyncSection } from './RepositoryForkSyncSection' import { translate } from '@/i18n/i18n' +import { RepositoryWindowsRuntimeSection } from './RepositoryWindowsRuntimeSection' +import { matchesRepositoryIdentitySearch } from './repository-identity-search' +import { RepositoryWorktreeDefaultsSection } from './RepositoryWorktreeDefaultsSection' +import { getProjectRuntimeSessionSummary } from './repository-runtime-session-summary' export { getRepositoryPaneSearchEntries } +export { matchesRepositoryIdentitySearch } from './repository-identity-search' type RepositoryPaneRepoUpdate = Omit, 'sourceControlAi'> & { sourceControlAi?: Repo['sourceControlAi'] | null } +const EMPTY_WSL_DISTROS: string[] = [] + type RepositoryPaneProps = { repo: Repo yamlHooks: OrcaHooks | null @@ -36,16 +49,15 @@ type RepositoryPaneProps = { mayNeedUpdate: boolean updateRepo: (repoId: string, updates: RepositoryPaneRepoUpdate) => void removeProject: (repoId: string) => void -} - -export function matchesRepositoryIdentitySearch(query: string, repo: Repo): boolean { - const normalizedQuery = normalizeSettingsSearchQuery(query) - if (!normalizedQuery) { - return false - } - return [repo.displayName, repo.path].some((value) => - value.toLowerCase().includes(normalizedQuery) - ) + project?: Project | null + isLocalWindowsProject?: boolean + wslAvailable?: boolean + wslDistros?: string[] + wslCapabilitiesLoading?: boolean + updateProject?: ( + projectId: string, + updates: ProjectUpdateArgs['updates'] + ) => void | Promise } export function RepositoryPane({ @@ -55,11 +67,20 @@ export function RepositoryPane({ hooksInspectionReady, mayNeedUpdate, updateRepo, - removeProject + removeProject, + project = null, + isLocalWindowsProject = false, + wslAvailable = false, + wslDistros = EMPTY_WSL_DISTROS, + wslCapabilitiesLoading = false, + updateProject }: RepositoryPaneProps): React.JSX.Element { const isFolder = isFolderRepo(repo) const searchQuery = useAppStore((state) => state.settingsSearchQuery) const settings = useAppStore((state) => state.settings) + const runtimeSessionSummary = useAppStore( + useShallow((state) => getProjectRuntimeSessionSummary(state, repo.id)) + ) const symlinksEnabled = settings?.experimentalWorktreeSymlinks const [confirmingRemove, setConfirmingRemove] = useState(null) const [copiedTemplate, setCopiedTemplate] = useState(false) @@ -123,7 +144,7 @@ export function RepositoryPane({ }, 1500) } - const allEntries = getRepositoryPaneSearchEntries(repo) + const allEntries = getRepositoryPaneSearchEntries(repo, { isLocalWindowsProject }) const identityEntryTitles = new Set([ translate('auto.components.settings.repository.search.7e1e456a95', 'Display Name'), translate('auto.components.settings.repository.search.b24f00294a', 'Project Icon'), @@ -133,6 +154,7 @@ export function RepositoryPane({ ), translate('auto.components.settings.repository.search.094adbe930', 'Default Worktree Base'), translate('auto.components.settings.repository.search.443d127b5a', 'Worktree Location'), + translate('auto.components.settings.repository.search.projectRuntime', 'Project Runtime'), translate('auto.components.settings.repository.search.c5266c2c9d', 'Remove Project') ]) const identityEntries = allEntries.filter((entry) => identityEntryTitles.has(entry.title)) @@ -152,6 +174,7 @@ export function RepositoryPane({ const symlinkEntries = allEntries.filter((entry) => entry.title === 'Worktree Symlinks') const sourceControlAiEntries = allEntries.filter((entry) => entry.title === 'Git AI Author') const hostSetupEntries = allEntries.filter((entry) => entry.title === 'Available Hosts') + const projectRuntimeEntries = allEntries.filter((entry) => entry.title === 'Project Runtime') const removeProjectLabel = confirmingRemove === repo.id ? 'Confirm Remove Project' : 'Remove Project' @@ -288,93 +311,33 @@ export function RepositoryPane({ searchEntries={hostSetupEntries} /> + + - - - updateRepo(repo.id, { worktreeBaseRef: ref })} - onUsePrimary={() => updateRepo(repo.id, { worktreeBaseRef: undefined })} - /> - - - -
- - {repo.worktreeBasePath ? ( - - ) : null} -
- - updateRepo(repo.id, { worktreeBasePath: text.trim() ? text : undefined }) - } - className="h-9 text-sm" - /> -

- {translate( - 'auto.components.settings.RepositoryPane.15a99d9b9f', - 'Relative paths resolve from this project root.' - )} -

-
+ /> ) : null} diff --git a/src/renderer/src/components/settings/RepositoryWindowsRuntimeSection.tsx b/src/renderer/src/components/settings/RepositoryWindowsRuntimeSection.tsx new file mode 100644 index 00000000000..23831b7f601 --- /dev/null +++ b/src/renderer/src/components/settings/RepositoryWindowsRuntimeSection.tsx @@ -0,0 +1,77 @@ +import type { GlobalSettings, Project, ProjectUpdateArgs } from '../../../../shared/types' +import { SearchableSetting } from './SearchableSetting' +import type { SettingsSearchEntry } from './settings-search' +import { matchesSettingsSearch } from './settings-search' +import { ProjectWindowsRuntimeSetting } from './ProjectWindowsRuntimeSetting' +import type { ProjectRuntimeSessionSummary } from './repository-runtime-session-summary' +import { translate } from '@/i18n/i18n' + +type RepositoryWindowsRuntimeSectionProps = { + repoDisplayName: string + project: Project | null + settings: Pick | null + isLocalWindowsProject: boolean + wslAvailable: boolean + wslDistros: string[] + wslCapabilitiesLoading: boolean + runtimeSessionSummary?: ProjectRuntimeSessionSummary + updateProject?: ( + projectId: string, + updates: ProjectUpdateArgs['updates'] + ) => void | Promise + forceVisible: boolean + searchQuery: string + searchEntries: SettingsSearchEntry[] +} + +export function RepositoryWindowsRuntimeSection({ + repoDisplayName, + project, + settings, + isLocalWindowsProject, + wslAvailable, + wslDistros, + wslCapabilitiesLoading, + runtimeSessionSummary, + updateProject, + forceVisible, + searchQuery, + searchEntries +}: RepositoryWindowsRuntimeSectionProps): React.JSX.Element | null { + if (!settings || !project || !updateProject || !isLocalWindowsProject) { + return null + } + + return ( + + + + ) +} diff --git a/src/renderer/src/components/settings/RepositoryWorktreeDefaultsSection.tsx b/src/renderer/src/components/settings/RepositoryWorktreeDefaultsSection.tsx new file mode 100644 index 00000000000..ab13d3cfb7a --- /dev/null +++ b/src/renderer/src/components/settings/RepositoryWorktreeDefaultsSection.tsx @@ -0,0 +1,100 @@ +import type { GlobalSettings, Repo } from '../../../../shared/types' +import { Button } from '../ui/button' +import { Label } from '../ui/label' +import { BaseRefPicker } from './BaseRefPicker' +import { RepoSettingsDraftInput } from './RepositorySettingsDraftInput' +import { SearchableSetting } from './SearchableSetting' +import { translate } from '@/i18n/i18n' + +type RepositoryWorktreeDefaultsUpdate = Pick + +type RepositoryWorktreeDefaultsSectionProps = { + repo: Repo + settings: Pick | null + updateRepo: (repoId: string, updates: Partial) => void + forceVisible: boolean +} + +export function RepositoryWorktreeDefaultsSection({ + repo, + settings, + updateRepo, + forceVisible +}: RepositoryWorktreeDefaultsSectionProps): React.JSX.Element { + return ( + <> + + + updateRepo(repo.id, { worktreeBaseRef: ref })} + onUsePrimary={() => updateRepo(repo.id, { worktreeBaseRef: undefined })} + /> + + + +
+ + {repo.worktreeBasePath ? ( + + ) : null} +
+ + updateRepo(repo.id, { worktreeBasePath: text.trim() ? text : undefined }) + } + className="h-9 text-sm" + /> +

+ {translate( + 'auto.components.settings.RepositoryPane.15a99d9b9f', + 'Relative paths resolve from this project root.' + )} +

+
+ + ) +} diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index 5d49c817cb4..0685a70bab2 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -16,6 +16,7 @@ import { applyDocumentTheme } from '@/lib/document-theme' import { useConfirmationDialog } from '@/components/confirmation-dialog' import { SCROLLBACK_PRESETS_MB, getFallbackTerminalFonts } from './SettingsConstants' import { DEFAULT_APP_FONT_FAMILY, getDefaultVoiceSettings } from '../../../../shared/constants' +import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' import { GeneralPane } from './GeneralPane' import { BrowserPane } from './BrowserPane' import { AppearancePane } from './AppearancePane' @@ -80,6 +81,7 @@ import { GLOBAL_AGENT_SKILL_SOURCE_KINDS, useInstalledAgentSkill } from '@/hooks/useInstalledAgentSkills' +import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime' import { deriveNeededRepoIds, deriveNeededSectionIds, @@ -87,6 +89,7 @@ import { getRuntimeTargetIdentity } from './settings-load-performance' import { translate } from '@/i18n/i18n' +import { getProjectHostSetupProjectionFromState } from '../../store/selectors' const SETTINGS_NAV_GROUPS = [ { @@ -233,6 +236,9 @@ function Settings(): React.JSX.Element { const fetchKeybindings = useAppStore((s) => s.fetchKeybindings) const closeSettingsPage = useAppStore((s) => s.closeSettingsPage) const repos = useAppStore((s) => s.repos) + const projects = useAppStore((s) => s.projects) + const projectHostSetups = useAppStore((s) => s.projectHostSetups) + const updateProject = useAppStore((s) => s.updateProject) const updateRepo = useAppStore((s) => s.updateRepo) const removeProject = useAppStore((s) => s.removeProject) const settingsNavigationTarget = useAppStore((s) => s.settingsNavigationTarget) @@ -251,11 +257,14 @@ function Settings(): React.JSX.Element { const isMac = isMacUserAgent() const isWebClient = isWebClientLocation() const showDesktopOnlySettings = !isWebClient + const activeSkillRuntime = useActiveProjectSkillRuntime() const orchestrationSkill = useInstalledAgentSkill(ORCHESTRATION_SKILL_NAME, { + discoveryTarget: activeSkillRuntime.discoveryTarget, sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS }) const computerUseSkill = useInstalledAgentSkill(COMPUTER_USE_SKILL_NAME, { enabled: showDesktopOnlySettings, + discoveryTarget: activeSkillRuntime.discoveryTarget, sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS }) const [voiceModelStatesLoading, setVoiceModelStatesLoading] = useState(showDesktopOnlySettings) @@ -638,6 +647,22 @@ function Settings(): React.JSX.Element { () => new Set(visibleNavSections.map((section) => section.id)), [visibleNavSections] ) + const projectByRepoId = useMemo(() => { + const projection = getProjectHostSetupProjectionFromState({ + repos, + projects, + projectHostSetups + }) + const projectById = new Map(projection.projects.map((project) => [project.id, project])) + const nextProjectByRepoId = new Map() + for (const setup of projection.setups) { + const project = projectById.get(setup.projectId) + if (project && setup.repoId.trim()) { + nextProjectByRepoId.set(setup.repoId, project) + } + } + return nextProjectByRepoId + }, [projectHostSetups, projects, repos]) const neededSectionIds = useMemo( () => deriveNeededSectionIds({ @@ -655,13 +680,17 @@ function Settings(): React.JSX.Element { ) const runtimeTarget = useMemo(() => getActiveRuntimeTarget(settings), [settings]) const hasActiveRuntimeEnvironment = Boolean(settings?.activeRuntimeEnvironmentId?.trim()) + const needsRepoWindowsRuntimeCapabilities = [...neededSectionIds].some((sectionId) => + sectionId.startsWith('repo-') + ) const shouldLoadWindowsTerminalCapabilities = hasActiveRuntimeEnvironment || ((isWindows || isWebClient) && (neededSectionIds.has('terminal') || neededSectionIds.has('general') || neededSectionIds.has('accounts') || - neededSectionIds.has('agents'))) + neededSectionIds.has('agents') || + needsRepoWindowsRuntimeCapabilities)) // Why: General owns the Orca CLI controls, including WSL skill-location setup. const windowsTerminalCapabilities = useWindowsTerminalCapabilities( shouldLoadWindowsTerminalCapabilities, @@ -1123,6 +1152,7 @@ function Settings(): React.JSX.Element { updateSettings={updateSettings} wslSupportedPlatform={wslSupportedPlatform} wslAvailable={windowsTerminalCapabilities.wslAvailable} + wslDistros={windowsTerminalCapabilities.wslDistros} wslCapabilitiesLoading={windowsTerminalCapabilities.isLoading} /> ) : null} @@ -1520,6 +1550,7 @@ function Settings(): React.JSX.Element { {repos.map((repo) => { const repoSectionId = `repo-${repo.id}` const repoHooksState = repoHooksMap[repo.id] + const project = projectByRepoId.get(repo.id) ?? null return ( ) : null} diff --git a/src/renderer/src/components/settings/TerminalAdvancedSection.tsx b/src/renderer/src/components/settings/TerminalAdvancedSection.tsx new file mode 100644 index 00000000000..2f9b5687b0c --- /dev/null +++ b/src/renderer/src/components/settings/TerminalAdvancedSection.tsx @@ -0,0 +1,277 @@ +import type { GlobalSettings } from '../../../../shared/types' +import { Input } from '../ui/input' +import { ToggleGroup, ToggleGroupItem } from '../ui/toggle-group' +import { clampNumber } from '@/lib/terminal-theme' +import { + SettingsRow, + SettingsSegmentedControl, + SettingsSubsectionHeader +} from './SettingsFormControls' +import { SCROLLBACK_PRESETS_MB } from './SettingsConstants' +import { SearchableSetting } from './SearchableSetting' +import { matchesSettingsSearch } from './settings-search' +import { getTerminalWindowsPowershellImplementationSearchEntry } from './terminal-windows-search' +import { TerminalMacKeyboardSection } from './TerminalMacKeyboardSection' +import { translate } from '@/i18n/i18n' + +type TerminalAdvancedSectionProps = { + settings: GlobalSettings + updateSettings: (updates: Partial) => void + scrollbackMode: 'preset' | 'custom' + setScrollbackMode: (mode: 'preset' | 'custom') => void + searchQuery: string + showWindowsPowerShellImplementation: boolean + pwshAvailable?: boolean + isMac: boolean +} + +export function TerminalAdvancedSection({ + settings, + updateSettings, + scrollbackMode, + setScrollbackMode, + searchQuery, + showWindowsPowerShellImplementation, + pwshAvailable, + isMac +}: TerminalAdvancedSectionProps): React.JSX.Element { + const scrollbackMb = Math.max(1, Math.round(settings.terminalScrollbackBytes / 1_000_000)) + const isPreset = SCROLLBACK_PRESETS_MB.includes( + scrollbackMb as (typeof SCROLLBACK_PRESETS_MB)[number] + ) + const scrollbackToggleValue = + scrollbackMode === 'custom' ? 'custom' : isPreset ? `${scrollbackMb}` : 'custom' + const powerShellImplementation = settings.terminalWindowsPowerShellImplementation ?? 'auto' + + return ( +
+ + +
+ + + { + if (!value) { + return + } + if (value === 'custom') { + setScrollbackMode('custom') + return + } + + setScrollbackMode('preset') + updateSettings({ + terminalScrollbackBytes: Number(value) * 1_000_000 + }) + }} + variant="outline" + size="sm" + className="h-8 flex-wrap justify-end" + > + {SCROLLBACK_PRESETS_MB.map((preset) => ( + + {preset} {translate('auto.components.settings.TerminalPane.12e06178fa', 'MB')} + + ))} + + {translate('auto.components.settings.TerminalPane.907b0b9d3e', 'Custom')} + + + {scrollbackMode === 'custom' ? ( +
+ { + const value = Number(e.target.value) + if (Number.isFinite(value)) { + updateSettings({ + terminalScrollbackBytes: clampNumber(value, 1, 256) * 1_000_000 + }) + } + }} + className="number-input-clean w-24 tabular-nums" + /> + + {translate('auto.components.settings.TerminalPane.12e06178fa', 'MB')} + +
+ ) : null} +
+ } + /> + + + + { + const value = e.target.value + updateSettings({ terminalWordSeparator: value || undefined }) + }} + placeholder={` ()[]{},'"\``} + className="w-56 font-mono text-xs" + /> + } + /> + + + {showWindowsPowerShellImplementation && + matchesSettingsSearch( + searchQuery, + getTerminalWindowsPowershellImplementationSearchEntry() + ) ? ( + + + {translate( + 'auto.components.settings.TerminalPane.a016ffbeed', + 'Auto uses Windows PowerShell now and switches to PowerShell 7+ when installed.' + )}{' '} + + {translate( + 'auto.components.settings.TerminalPane.822f62ddcd', + 'Download PowerShell 7+' + )} + + . + + ) + } + control={ + + updateSettings({ terminalWindowsPowerShellImplementation: value }) + } + options={[ + { + value: 'auto', + label: translate('auto.components.settings.TerminalPane.43c2ff7b0e', 'Auto') + }, + { + value: 'powershell.exe', + label: translate( + 'auto.components.settings.TerminalPane.d26174e1dd', + 'Windows PowerShell' + ) + }, + { + value: 'pwsh.exe', + label: translate( + 'auto.components.settings.TerminalPane.96be03b8eb', + 'PowerShell 7+' + ), + disabled: !pwshAvailable + } + ]} + /> + } + /> + + ) : null} + + {isMac ? ( + + ) : null} + +
+ ) +} diff --git a/src/renderer/src/components/settings/TerminalInteractionSection.tsx b/src/renderer/src/components/settings/TerminalInteractionSection.tsx new file mode 100644 index 00000000000..705b0b7e784 --- /dev/null +++ b/src/renderer/src/components/settings/TerminalInteractionSection.tsx @@ -0,0 +1,178 @@ +import type { GlobalSettings } from '../../../../shared/types' +import { SettingsSubsectionHeader, SettingsSwitchRow } from './SettingsFormControls' +import { SearchableSetting } from './SearchableSetting' +import { matchesSettingsSearch } from './settings-search' +import { getTerminalRightClickToPasteSearchEntry } from './terminal-windows-search' +import { OSC52_CLIPBOARD_SETTING_ID } from '../terminal-pane/osc52-clipboard-setting-anchor' +import { translate } from '@/i18n/i18n' + +type TerminalInteractionSectionProps = { + settings: GlobalSettings + updateSettings: (updates: Partial) => void + searchQuery: string + isWindows: boolean +} + +export function TerminalInteractionSection({ + settings, + updateSettings, + searchQuery, + isWindows +}: TerminalInteractionSectionProps): React.JSX.Element { + return ( +
+ + +
+ {/* Why: the Windows-only right-click toggle lives in this section, so the + section must also match that search term or settings search would hide + the control even though it is present. */} + {isWindows && + matchesSettingsSearch(searchQuery, getTerminalRightClickToPasteSearchEntry()) ? ( + + + updateSettings({ + terminalRightClickToPaste: !settings.terminalRightClickToPaste + }) + } + /> + + ) : null} + + + + updateSettings({ + terminalFocusFollowsMouse: !settings.terminalFocusFollowsMouse + }) + } + /> + + + + + updateSettings({ + terminalClipboardOnSelect: !settings.terminalClipboardOnSelect + }) + } + /> + + + + + updateSettings({ + terminalAllowOsc52Clipboard: !settings.terminalAllowOsc52Clipboard + }) + } + /> + +
+
+ ) +} diff --git a/src/renderer/src/components/settings/TerminalMacKeyboardSection.tsx b/src/renderer/src/components/settings/TerminalMacKeyboardSection.tsx new file mode 100644 index 00000000000..0e4036f2478 --- /dev/null +++ b/src/renderer/src/components/settings/TerminalMacKeyboardSection.tsx @@ -0,0 +1,151 @@ +import type { GlobalSettings } from '../../../../shared/types' +import { useDetectedOptionAsAlt } from '@/lib/keyboard-layout/use-effective-mac-option-as-alt' +import { translate } from '@/i18n/i18n' +import { SearchableSetting } from './SearchableSetting' +import { SettingsRow, SettingsSegmentedControl, SettingsSwitchRow } from './SettingsFormControls' + +type TerminalMacKeyboardSectionProps = { + settings: GlobalSettings + updateSettings: (updates: Partial) => void +} + +export function TerminalMacKeyboardSection({ + settings, + updateSettings +}: TerminalMacKeyboardSectionProps): React.JSX.Element { + const detectedLayout = useDetectedOptionAsAlt() + const detectedLayoutLabel = + detectedLayout === 'us' + ? 'US English — Option sends Alt/Esc sequences' + : detectedLayout === 'non-us' + ? 'non-US layout — Option composes characters like @, €, [, ]' + : 'unknown layout — Option composes characters (safe default)' + + return ( + <> + + updateSettings({ terminalMacOptionAsAlt: option })} + options={[ + { + value: 'auto', + label: translate('auto.components.settings.TerminalPane.43c2ff7b0e', 'Auto') + }, + { + value: 'true', + label: translate('auto.components.settings.TerminalPane.badb1219fc', 'Both') + }, + { + value: 'left', + label: translate('auto.components.settings.TerminalPane.e7aec1fd60', 'Left') + }, + { + value: 'right', + label: translate('auto.components.settings.TerminalPane.c73d510938', 'Right') + }, + { + value: 'false', + label: translate('auto.components.settings.TerminalPane.3fe1c5bfe0', 'Off') + } + ]} + /> + } + /> + + + + + updateSettings({ + terminalJISYenToBackslash: !settings.terminalJISYenToBackslash + }) + } + /> + + + ) +} diff --git a/src/renderer/src/components/settings/TerminalPane.pwsh.test.ts b/src/renderer/src/components/settings/TerminalPane.pwsh.test.ts index 73fc857b2be..ebf575749c9 100644 --- a/src/renderer/src/components/settings/TerminalPane.pwsh.test.ts +++ b/src/renderer/src/components/settings/TerminalPane.pwsh.test.ts @@ -107,8 +107,14 @@ vi.mock('./SettingsFormControls', () => ({ }) { return options?.map((option) => option.label) ?? null }, - SettingsSubsectionHeader: function SettingsSubsectionHeader() { - return null + SettingsSubsectionHeader: function SettingsSubsectionHeader({ + title, + description + }: { + title?: unknown + description?: unknown + }) { + return [title, description] }, SettingsSwitchRow: function SettingsSwitchRow() { return null @@ -136,6 +142,30 @@ vi.mock('./GhosttyImportModal', () => ({ } })) +vi.mock('./ManageSessionsSection', () => ({ + ManageSessionsSection: function ManageSessionsSection() { + return null + } +})) + +vi.mock('./TerminalInteractionSection', () => ({ + TerminalInteractionSection: function TerminalInteractionSection() { + return null + } +})) + +vi.mock('./TerminalRenderingSection', () => ({ + TerminalRenderingSection: function TerminalRenderingSection() { + return null + } +})) + +vi.mock('./TerminalSetupScriptSection', () => ({ + TerminalSetupScriptSection: function TerminalSetupScriptSection() { + return null + } +})) + vi.mock('@/lib/terminal-theme', () => ({ clampNumber: (v: number, min: number, max: number) => Math.max(min, Math.min(max, v)), resolveEffectiveTerminalAppearance: () => ({ @@ -165,6 +195,10 @@ function getPropNodes(el: ReactElementLike): unknown[] { return nodes } +function renderFunctionElement(el: ReactElementLike): unknown { + return typeof el.type === 'function' ? el.type(el.props) : undefined +} + function collectText(node: unknown): string { if (node == null) { return '' @@ -179,6 +213,10 @@ function collectText(node: unknown): string { return node.map(collectText).join('') } const el = node as ReactElementLike + const rendered = renderFunctionElement(el) + if (rendered !== undefined) { + return collectText(rendered) + } return getPropNodes(el).map(collectText).join('') } @@ -203,6 +241,10 @@ function findAnchorByText(node: unknown, text: string): ReactElementLike | null if (typeName === 'a' && collectText(el.props.children).includes(text)) { return el } + const rendered = renderFunctionElement(el) + if (rendered !== undefined) { + return findAnchorByText(rendered, text) + } for (const child of getPropNodes(el)) { const found = findAnchorByText(child, text) if (found) { @@ -224,6 +266,10 @@ function hasShellIconFor(node: unknown, shell: string): boolean { if (typeName === 'ShellIcon' && el.props.shell === shell) { return true } + const rendered = renderFunctionElement(el) + if (rendered !== undefined) { + return hasShellIconFor(rendered, shell) + } return getPropNodes(el).some((child) => hasShellIconFor(child, shell)) } @@ -256,7 +302,7 @@ describe('TerminalPane PowerShell version setting', () => { expect(link?.props.href).toBe('https://github.com/PowerShell/PowerShell/releases/latest') }) - it('shows WSL as a Windows default shell option when available', () => { + it('does not show WSL as a Windows default shell option when available', () => { const element = TerminalPane({ settings: { terminalScrollbackBytes: 10_000_000, @@ -273,7 +319,10 @@ describe('TerminalPane PowerShell version setting', () => { gitBashAvailable: false }) - expect(collectText(element)).toContain('WSL') + const text = collectText(element) + expect(text).toContain('PowerShell') + expect(text).toContain('Command Prompt') + expect(text).not.toContain('WSL') }) it('shows Windows shell controls for a remote Windows host on a non-Windows client', () => { @@ -297,7 +346,7 @@ describe('TerminalPane PowerShell version setting', () => { const text = collectText(element) expect(text).toContain('Default shell for new terminal panes on Windows') expect(text).toContain('Command Prompt') - expect(text).toContain('WSL') + expect(text).not.toContain('WSL') }) it('hides WSL as a Windows default shell option when unavailable', () => { @@ -319,7 +368,7 @@ describe('TerminalPane PowerShell version setting', () => { expect(collectText(element)).not.toContain('WSL') }) - it('shows WSL distro choices when WSL is the selected Windows shell', () => { + it('does not show WSL distro choices for a persisted legacy WSL shell', () => { const element = TerminalPane({ settings: { terminalScrollbackBytes: 10_000_000, @@ -338,10 +387,12 @@ describe('TerminalPane PowerShell version setting', () => { }) const text = collectText(element) - expect(text).toContain('Choose which WSL distribution') - expect(text).toContain('Windows default') - expect(text).toContain('Ubuntu') - expect(text).toContain('Debian') + expect(text).toContain('PowerShell') + expect(text).toContain('Command Prompt') + expect(text).not.toContain('Choose which WSL distribution') + expect(text).not.toContain('Windows default') + expect(text).not.toContain('Ubuntu') + expect(text).not.toContain('Debian') }) it('shows Git Bash as a Windows default shell option when bash.exe is detected', () => { diff --git a/src/renderer/src/components/settings/TerminalPane.tsx b/src/renderer/src/components/settings/TerminalPane.tsx index dd00d685bb4..679589846b6 100644 --- a/src/renderer/src/components/settings/TerminalPane.tsx +++ b/src/renderer/src/components/settings/TerminalPane.tsx @@ -1,19 +1,5 @@ -/* eslint-disable max-lines -- Why: TerminalPane keeps terminal workflow, runtime, and recovery - settings together so search shows one focused terminal behavior surface. */ -import type { GlobalSettings, SetupScriptLaunchMode } from '../../../../shared/types' -import { Input } from '../ui/input' +import type { GlobalSettings } from '../../../../shared/types' import { Separator } from '../ui/separator' -import { ToggleGroup, ToggleGroupItem } from '../ui/toggle-group' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' -import { clampNumber } from '@/lib/terminal-theme' -import { - SettingsRow, - SettingsSegmentedControl, - SettingsSubsectionHeader, - SettingsSwitchRow -} from './SettingsFormControls' -import { SCROLLBACK_PRESETS_MB } from './SettingsConstants' -import { SearchableSetting } from './SearchableSetting' import { matchesSettingsSearch } from './settings-search' import { useAppStore } from '../../store' import { isMacUserAgent, isWindowsUserAgent } from '@/components/terminal-pane/pane-helpers' @@ -31,25 +17,23 @@ import { getTerminalWindowsPowershellImplementationSearchEntry, getTerminalWindowsShellSearchEntry } from './terminal-windows-search' -import { useDetectedOptionAsAlt } from '@/lib/keyboard-layout/use-effective-mac-option-as-alt' import { ManageSessionsSection } from './ManageSessionsSection' -import { OSC52_CLIPBOARD_SETTING_ID } from '../terminal-pane/osc52-clipboard-setting-anchor' -import { WINDOWS_GIT_BASH_SHELL } from '../../../../shared/windows-terminal-shell' -import { translate } from '@/i18n/i18n' -import { ShellIcon } from '../tab-bar/shell-icons' - -const EMPTY_WSL_DISTROS: string[] = [] +import { TerminalAdvancedSection } from './TerminalAdvancedSection' +import { TerminalInteractionSection } from './TerminalInteractionSection' +import { TerminalRenderingSection } from './TerminalRenderingSection' +import { TerminalSetupScriptSection } from './TerminalSetupScriptSection' +import { TerminalWindowsShellSection } from './TerminalWindowsShellSection' type TerminalPaneProps = { settings: GlobalSettings updateSettings: (updates: Partial) => void scrollbackMode: 'preset' | 'custom' setScrollbackMode: (mode: 'preset' | 'custom') => void - /** Whether WSL is installed on this Windows machine. */ + /** Deprecated: WSL selection now belongs to Project Runtime settings. */ wslAvailable?: boolean - /** Installed WSL distro names, used to choose the default WSL terminal target. */ + /** Deprecated: WSL selection now belongs to Project Runtime settings. */ wslDistros?: string[] - /** Whether WSL capability probing is still in flight. */ + /** Deprecated: WSL selection now belongs to Project Runtime settings. */ wslCapabilitiesLoading?: boolean /** Whether PowerShell 7+ (pwsh.exe) is installed on this Windows machine. */ pwshAvailable?: boolean @@ -59,23 +43,11 @@ type TerminalPaneProps = { isWindowsTerminalHost?: boolean } -function windowsShellLabel(shell: string, label: string): React.JSX.Element { - return ( - - - {label} - - ) -} - export function TerminalPane({ settings, updateSettings, scrollbackMode, setScrollbackMode, - wslAvailable, - wslDistros = EMPTY_WSL_DISTROS, - wslCapabilitiesLoading = false, pwshAvailable, gitBashAvailable = false, isWindowsTerminalHost @@ -84,600 +56,44 @@ export function TerminalPane({ const isWindows = isWindowsUserAgent() const showWindowsHostSettings = isWindowsTerminalHost ?? isWindows const isMac = isMacUserAgent() - const detectedLayout = useDetectedOptionAsAlt() - const detectedLayoutLabel = - detectedLayout === 'us' - ? 'US English — Option sends Alt/Esc sequences' - : detectedLayout === 'non-us' - ? 'non-US layout — Option composes characters like @, €, [, ]' - : 'unknown layout — Option composes characters (safe default)' - const scrollbackMb = Math.max(1, Math.round(settings.terminalScrollbackBytes / 1_000_000)) - const isPreset = SCROLLBACK_PRESETS_MB.includes( - scrollbackMb as (typeof SCROLLBACK_PRESETS_MB)[number] - ) - const scrollbackToggleValue = - scrollbackMode === 'custom' ? 'custom' : isPreset ? `${scrollbackMb}` : 'custom' - const windowsShell = settings.terminalWindowsShell ?? 'powershell.exe' - const selectedWslDistroName = settings.terminalWindowsWslDistro?.trim() || null - const selectedWslDistro = selectedWslDistroName || '__default__' - const wslDistroOptions = - selectedWslDistroName && !wslDistros.includes(selectedWslDistroName) - ? [selectedWslDistroName, ...wslDistros] - : wslDistros - const powerShellImplementation = settings.terminalWindowsPowerShellImplementation ?? 'auto' + const rawWindowsShell = settings.terminalWindowsShell ?? 'powershell.exe' + const windowsShell = rawWindowsShell === 'wsl.exe' ? 'powershell.exe' : rawWindowsShell const showWindowsPowerShellImplementation = showWindowsHostSettings && windowsShell === 'powershell.exe' - const showGitBashOption = gitBashAvailable || windowsShell === WINDOWS_GIT_BASH_SHELL const visibleSections = [ showWindowsHostSettings && matchesSettingsSearch(searchQuery, getTerminalWindowsShellSearchEntry()) ? ( -
- - -
- - updateSettings({ terminalWindowsShell: value })} - options={[ - { - value: 'powershell.exe', - label: windowsShellLabel( - 'powershell.exe', - translate('auto.components.settings.TerminalPane.eb7fc4d98a', 'PowerShell') - ), - ariaLabel: translate( - 'auto.components.settings.TerminalPane.eb7fc4d98a', - 'PowerShell' - ) - }, - { - value: 'cmd.exe', - label: windowsShellLabel( - 'cmd.exe', - translate( - 'auto.components.settings.TerminalPane.0f1b8669e6', - 'Command Prompt' - ) - ), - ariaLabel: translate( - 'auto.components.settings.TerminalPane.0f1b8669e6', - 'Command Prompt' - ) - }, - ...(showGitBashOption - ? [ - { - value: WINDOWS_GIT_BASH_SHELL, - label: windowsShellLabel( - WINDOWS_GIT_BASH_SHELL, - translate( - 'auto.components.settings.TerminalPane.f61ac77f16', - 'Git Bash' - ) - ), - ariaLabel: translate( - 'auto.components.settings.TerminalPane.f61ac77f16', - 'Git Bash' - ), - disabled: !gitBashAvailable - } - ] - : []), - ...(wslAvailable - ? [ - { - value: 'wsl.exe', - label: windowsShellLabel( - 'wsl.exe', - translate('auto.components.settings.TerminalPane.b637dd57a7', 'WSL') - ), - ariaLabel: translate( - 'auto.components.settings.TerminalPane.b637dd57a7', - 'WSL' - ) - } - ] - : []) - ]} - /> - } - /> - - {windowsShell === 'wsl.exe' ? ( - - - updateSettings({ - terminalWindowsWslDistro: value === '__default__' ? null : value - }) - } - disabled={wslCapabilitiesLoading || !wslAvailable} - > - - - - - - {translate( - 'auto.components.settings.TerminalPane.cc8c5ca224', - 'Windows default' - )} - - {wslDistroOptions.map((distro) => ( - - {distro} - - ))} - - - } - /> - - ) : null} -
-
+ ) : null, matchesSettingsSearch(searchQuery, getTerminalRenderingSearchEntries()) ? ( -
- - -
- - updateSettings({ terminalGpuAcceleration: option })} - options={[ - { - value: 'auto', - label: translate('auto.components.settings.TerminalPane.43c2ff7b0e', 'Auto') - }, - { - value: 'on', - label: translate('auto.components.settings.TerminalPane.9c0b1c1792', 'On') - }, - { - value: 'off', - label: translate('auto.components.settings.TerminalPane.3fe1c5bfe0', 'Off') - } - ]} - /> - } - /> - -
-
+ ) : null, matchesSettingsSearch(searchQuery, getTerminalPaneInteractionSearchEntries()) || (isWindows && matchesSettingsSearch(searchQuery, getTerminalRightClickToPasteSearchEntry())) ? ( -
- - -
- {/* Why: the Windows-only right-click toggle lives in this section, so the - section must also match that search term or settings search would hide - the control even though it is present. */} - {isWindows && - matchesSettingsSearch(searchQuery, getTerminalRightClickToPasteSearchEntry()) && ( - - - updateSettings({ - terminalRightClickToPaste: !settings.terminalRightClickToPaste - }) - } - /> - - )} - - - - updateSettings({ - skipCloseTerminalWithRunningProcessConfirm: - !settings.skipCloseTerminalWithRunningProcessConfirm - }) - } - /> - - - - - updateSettings({ - terminalFocusFollowsMouse: !settings.terminalFocusFollowsMouse - }) - } - /> - - - - - updateSettings({ - terminalClipboardOnSelect: !settings.terminalClipboardOnSelect - }) - } - /> - - - - - updateSettings({ - terminalAllowOsc52Clipboard: !settings.terminalAllowOsc52Clipboard - }) - } - /> - -
-
+ ) : null, matchesSettingsSearch(searchQuery, getTerminalSetupScriptSearchEntries()) ? ( -
- - -
- - { - if (!value) { - return - } - updateSettings({ - setupScriptLaunchMode: value as SetupScriptLaunchMode - }) - }} - variant="outline" - size="sm" - className="h-8 flex-wrap" - > - - {translate('auto.components.settings.TerminalPane.1158f8fd55', 'New Tab')} - - - {translate( - 'auto.components.settings.TerminalPane.332e8a2872', - 'Split Vertically' - )} - - - {translate( - 'auto.components.settings.TerminalPane.003df129fe', - 'Split Horizontally' - )} - - - } - /> - -
-
+ ) : null, matchesSettingsSearch(searchQuery, getManageSessionsSearchEntries()) ? ( @@ -691,385 +107,17 @@ export function TerminalPane({ (isMac && (matchesSettingsSearch(searchQuery, getTerminalMacOptionSearchEntries()) || matchesSettingsSearch(searchQuery, getTerminalMacYenSearchEntries()))) ? ( -
- - -
- - - { - if (!value) { - return - } - if (value === 'custom') { - setScrollbackMode('custom') - return - } - - setScrollbackMode('preset') - updateSettings({ - terminalScrollbackBytes: Number(value) * 1_000_000 - }) - }} - variant="outline" - size="sm" - className="h-8 flex-wrap justify-end" - > - {SCROLLBACK_PRESETS_MB.map((preset) => ( - - {preset}{' '} - {translate('auto.components.settings.TerminalPane.12e06178fa', 'MB')} - - ))} - - {translate('auto.components.settings.TerminalPane.907b0b9d3e', 'Custom')} - - - {scrollbackMode === 'custom' ? ( -
- { - const value = Number(e.target.value) - if (Number.isFinite(value)) { - updateSettings({ - terminalScrollbackBytes: clampNumber(value, 1, 256) * 1_000_000 - }) - } - }} - className="number-input-clean w-24 tabular-nums" - /> - - {translate('auto.components.settings.TerminalPane.12e06178fa', 'MB')} - -
- ) : null} -
- } - /> - - - - { - const value = e.target.value - updateSettings({ terminalWordSeparator: value || undefined }) - }} - placeholder={` ()[]{},'"\``} - className="w-56 font-mono text-xs" - /> - } - /> - - - {showWindowsPowerShellImplementation && - matchesSettingsSearch( - searchQuery, - getTerminalWindowsPowershellImplementationSearchEntry() - ) ? ( - - - {translate( - 'auto.components.settings.TerminalPane.a016ffbeed', - 'Auto uses Windows PowerShell now and switches to PowerShell 7+ when installed.' - )}{' '} - - {translate( - 'auto.components.settings.TerminalPane.822f62ddcd', - 'Download PowerShell 7+' - )} - - . - - ) - } - control={ - - updateSettings({ terminalWindowsPowerShellImplementation: value }) - } - options={[ - { - value: 'auto', - label: translate('auto.components.settings.TerminalPane.43c2ff7b0e', 'Auto') - }, - { - value: 'powershell.exe', - label: translate( - 'auto.components.settings.TerminalPane.d26174e1dd', - 'Windows PowerShell' - ) - }, - { - value: 'pwsh.exe', - label: translate( - 'auto.components.settings.TerminalPane.96be03b8eb', - 'PowerShell 7+' - ), - disabled: !pwshAvailable - } - ]} - /> - } - /> - - ) : null} - - {isMac ? ( - <> - - updateSettings({ terminalMacOptionAsAlt: option })} - options={[ - { - value: 'auto', - label: translate( - 'auto.components.settings.TerminalPane.43c2ff7b0e', - 'Auto' - ) - }, - { - value: 'true', - label: translate( - 'auto.components.settings.TerminalPane.badb1219fc', - 'Both' - ) - }, - { - value: 'left', - label: translate( - 'auto.components.settings.TerminalPane.e7aec1fd60', - 'Left' - ) - }, - { - value: 'right', - label: translate( - 'auto.components.settings.TerminalPane.c73d510938', - 'Right' - ) - }, - { - value: 'false', - label: translate( - 'auto.components.settings.TerminalPane.3fe1c5bfe0', - 'Off' - ) - } - ]} - /> - } - /> - - - - - updateSettings({ - terminalJISYenToBackslash: !settings.terminalJISYenToBackslash - }) - } - /> - - - ) : null} - -
+ ) : null ].filter(Boolean) diff --git a/src/renderer/src/components/settings/TerminalRenderingSection.tsx b/src/renderer/src/components/settings/TerminalRenderingSection.tsx new file mode 100644 index 00000000000..5917247171f --- /dev/null +++ b/src/renderer/src/components/settings/TerminalRenderingSection.tsx @@ -0,0 +1,97 @@ +import type { GlobalSettings } from '../../../../shared/types' +import { + SettingsRow, + SettingsSegmentedControl, + SettingsSubsectionHeader +} from './SettingsFormControls' +import { SearchableSetting } from './SearchableSetting' +import { translate } from '@/i18n/i18n' + +type TerminalRenderingSectionProps = { + settings: GlobalSettings + updateSettings: (updates: Partial) => void +} + +export function TerminalRenderingSection({ + settings, + updateSettings +}: TerminalRenderingSectionProps): React.JSX.Element { + return ( +
+ + +
+ + updateSettings({ terminalGpuAcceleration: option })} + options={[ + { + value: 'auto', + label: translate('auto.components.settings.TerminalPane.43c2ff7b0e', 'Auto') + }, + { + value: 'on', + label: translate('auto.components.settings.TerminalPane.9c0b1c1792', 'On') + }, + { + value: 'off', + label: translate('auto.components.settings.TerminalPane.3fe1c5bfe0', 'Off') + } + ]} + /> + } + /> + +
+
+ ) +} diff --git a/src/renderer/src/components/settings/TerminalSetupScriptSection.tsx b/src/renderer/src/components/settings/TerminalSetupScriptSection.tsx new file mode 100644 index 00000000000..c3b6f549cd8 --- /dev/null +++ b/src/renderer/src/components/settings/TerminalSetupScriptSection.tsx @@ -0,0 +1,120 @@ +import type { GlobalSettings, SetupScriptLaunchMode } from '../../../../shared/types' +import { ToggleGroup, ToggleGroupItem } from '../ui/toggle-group' +import { SettingsRow, SettingsSubsectionHeader } from './SettingsFormControls' +import { SearchableSetting } from './SearchableSetting' +import { translate } from '@/i18n/i18n' + +type TerminalSetupScriptSectionProps = { + settings: GlobalSettings + updateSettings: (updates: Partial) => void +} + +export function TerminalSetupScriptSection({ + settings, + updateSettings +}: TerminalSetupScriptSectionProps): React.JSX.Element { + return ( +
+ + +
+ + { + if (!value) { + return + } + updateSettings({ + setupScriptLaunchMode: value as SetupScriptLaunchMode + }) + }} + variant="outline" + size="sm" + className="h-8 flex-wrap" + > + + {translate('auto.components.settings.TerminalPane.1158f8fd55', 'New Tab')} + + + {translate( + 'auto.components.settings.TerminalPane.332e8a2872', + 'Split Vertically' + )} + + + {translate( + 'auto.components.settings.TerminalPane.003df129fe', + 'Split Horizontally' + )} + + + } + /> + +
+
+ ) +} diff --git a/src/renderer/src/components/settings/TerminalWindowsShellSection.tsx b/src/renderer/src/components/settings/TerminalWindowsShellSection.tsx new file mode 100644 index 00000000000..852367cd2fb --- /dev/null +++ b/src/renderer/src/components/settings/TerminalWindowsShellSection.tsx @@ -0,0 +1,130 @@ +import type { GlobalSettings } from '../../../../shared/types' +import { WINDOWS_GIT_BASH_SHELL } from '../../../../shared/windows-terminal-shell' +import { + SettingsRow, + SettingsSegmentedControl, + SettingsSubsectionHeader +} from './SettingsFormControls' +import { SearchableSetting } from './SearchableSetting' +import { translate } from '@/i18n/i18n' +import { ShellIcon } from '../tab-bar/shell-icons' + +type TerminalWindowsShellSectionProps = { + updateSettings: (updates: Partial) => void + windowsShell: string + gitBashAvailable: boolean +} + +function windowsShellLabel(shell: string, label: string): React.JSX.Element { + return ( + + + {label} + + ) +} + +export function TerminalWindowsShellSection({ + updateSettings, + windowsShell, + gitBashAvailable +}: TerminalWindowsShellSectionProps): React.JSX.Element { + const showGitBashOption = gitBashAvailable || windowsShell === WINDOWS_GIT_BASH_SHELL + + return ( +
+ + +
+ + updateSettings({ terminalWindowsShell: value })} + options={[ + { + value: 'powershell.exe', + label: windowsShellLabel( + 'powershell.exe', + translate('auto.components.settings.TerminalPane.eb7fc4d98a', 'PowerShell') + ), + ariaLabel: translate( + 'auto.components.settings.TerminalPane.eb7fc4d98a', + 'PowerShell' + ) + }, + { + value: 'cmd.exe', + label: windowsShellLabel( + 'cmd.exe', + translate( + 'auto.components.settings.TerminalPane.0f1b8669e6', + 'Command Prompt' + ) + ), + ariaLabel: translate( + 'auto.components.settings.TerminalPane.0f1b8669e6', + 'Command Prompt' + ) + }, + ...(showGitBashOption + ? [ + { + value: WINDOWS_GIT_BASH_SHELL, + label: windowsShellLabel( + WINDOWS_GIT_BASH_SHELL, + translate( + 'auto.components.settings.TerminalPane.f61ac77f16', + 'Git Bash' + ) + ), + ariaLabel: translate( + 'auto.components.settings.TerminalPane.f61ac77f16', + 'Git Bash' + ), + disabled: !gitBashAvailable + } + ] + : []) + ]} + /> + } + /> + +
+
+ ) +} diff --git a/src/renderer/src/components/settings/agents-search.ts b/src/renderer/src/components/settings/agents-search.ts index 8c9aa75c597..6941d8d9745 100644 --- a/src/renderer/src/components/settings/agents-search.ts +++ b/src/renderer/src/components/settings/agents-search.ts @@ -67,23 +67,6 @@ export const getAgentsPaneSearchEntries = createLocalizedCatalog(() => [ ), keywords: buildAgentSettingsKeywords() }, - { - title: translate('auto.components.settings.agents.search.ef804b7337', 'Agent Location'), - description: translate( - 'auto.components.settings.agents.search.cbdd7f3b9e', - 'Choose whether installed agents are detected on this device or in WSL.' - ), - keywords: [ - ...translateSearchKeyword('auto.components.settings.agents.search.96ba2373b6', 'agent'), - ...translateSearchKeyword('auto.components.settings.agents.search.d2952dfd74', 'location'), - ...translateSearchKeyword('auto.components.settings.agents.search.77c02fa3c3', 'windows'), - ...translateSearchKeyword('auto.components.settings.agents.search.d608654c03', 'wsl'), - ...translateSearchKeyword('auto.components.settings.agents.search.f622b8eb2a', 'linux'), - ...translateSearchKeyword('auto.components.settings.agents.search.839e82c81f', 'detect'), - ...translateSearchKeyword('auto.components.settings.agents.search.2814401339', 'installed'), - ...translateSearchKeyword('auto.components.settings.agents.search.719f53350c', 'path') - ] - }, { title: getAgentStatusHooksTitle(), description: getAgentStatusHooksDescription(), diff --git a/src/renderer/src/components/settings/general-project-runtime-search.ts b/src/renderer/src/components/settings/general-project-runtime-search.ts new file mode 100644 index 00000000000..93d11a7029d --- /dev/null +++ b/src/renderer/src/components/settings/general-project-runtime-search.ts @@ -0,0 +1,30 @@ +import { translate } from '@/i18n/i18n' +import { createLocalizedCatalog } from '@/i18n/localized-catalog' +import { translateSearchKeyword } from './settings-search-keywords' + +export const getGeneralProjectRuntimeSearchEntries = createLocalizedCatalog(() => [ + { + title: translate( + 'auto.components.settings.general.search.defaultProjectRuntime', + 'Default Project Runtime' + ), + description: translate( + 'auto.components.settings.general.search.defaultProjectRuntimeDescription', + 'Choose the runtime inherited by local Windows projects.' + ), + keywords: [ + ...translateSearchKeyword( + 'auto.components.settings.general.search.projectRuntime', + 'project runtime' + ), + ...translateSearchKeyword('auto.components.settings.general.search.runtime', 'runtime'), + ...translateSearchKeyword( + 'auto.components.settings.general.search.windowsHost', + 'windows host' + ), + ...translateSearchKeyword('auto.components.settings.general.search.wsl', 'wsl'), + ...translateSearchKeyword('auto.components.settings.general.search.distro', 'distro'), + ...translateSearchKeyword('auto.components.settings.general.search.execution', 'execution') + ] + } +]) diff --git a/src/renderer/src/components/settings/general-search.ts b/src/renderer/src/components/settings/general-search.ts index 993ee0d82e9..32ba94ba7dc 100644 --- a/src/renderer/src/components/settings/general-search.ts +++ b/src/renderer/src/components/settings/general-search.ts @@ -3,6 +3,7 @@ import { getGeneralEditorSearchEntries } from './general-editor-search' import { translate } from '@/i18n/i18n' import { searchKeywords, translateSearchKeyword } from './settings-search-keywords' import { createLocalizedCatalog } from '@/i18n/localized-catalog' +import { getGeneralProjectRuntimeSearchEntries } from './general-project-runtime-search' export { getGeneralEditorSearchEntries } from './general-editor-search' @@ -272,13 +273,22 @@ export const getGeneralSupportSearchEntries = createLocalizedCatalog(() => [ } ]) -export const getGeneralPaneSearchEntries = createLocalizedCatalog((): SettingsSearchEntry[] => [ - ...getGeneralWorkspaceSearchEntries(), - ...getGeneralNetworkSearchEntries(), - ...getGeneralNavigationSearchEntries(), - ...getGeneralEditorSearchEntries(), - ...getGeneralCliSearchEntries(), - ...getGeneralCacheTimerSearchEntries(), - ...getGeneralUpdateSearchEntries(), - ...getGeneralSupportSearchEntries() -]) +type GeneralPaneSearchOptions = { + includeProjectRuntime?: boolean +} + +export function getGeneralPaneSearchEntries( + options: GeneralPaneSearchOptions = {} +): SettingsSearchEntry[] { + return [ + ...getGeneralWorkspaceSearchEntries(), + ...getGeneralNetworkSearchEntries(), + ...getGeneralNavigationSearchEntries(), + ...(options.includeProjectRuntime === false ? [] : getGeneralProjectRuntimeSearchEntries()), + ...getGeneralEditorSearchEntries(), + ...getGeneralCliSearchEntries(), + ...getGeneralCacheTimerSearchEntries(), + ...getGeneralUpdateSearchEntries(), + ...getGeneralSupportSearchEntries() + ] +} diff --git a/src/renderer/src/components/settings/repository-identity-search.ts b/src/renderer/src/components/settings/repository-identity-search.ts new file mode 100644 index 00000000000..239090948ec --- /dev/null +++ b/src/renderer/src/components/settings/repository-identity-search.ts @@ -0,0 +1,12 @@ +import type { Repo } from '../../../../shared/types' +import { normalizeSettingsSearchQuery } from './settings-search' + +export function matchesRepositoryIdentitySearch(query: string, repo: Repo): boolean { + const normalizedQuery = normalizeSettingsSearchQuery(query) + if (!normalizedQuery) { + return false + } + return [repo.displayName, repo.path].some((value) => + value.toLowerCase().includes(normalizedQuery) + ) +} diff --git a/src/renderer/src/components/settings/repository-runtime-session-summary.ts b/src/renderer/src/components/settings/repository-runtime-session-summary.ts new file mode 100644 index 00000000000..0279683b22b --- /dev/null +++ b/src/renderer/src/components/settings/repository-runtime-session-summary.ts @@ -0,0 +1,59 @@ +import type { AppState } from '../../store/types' +import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id' + +export type ProjectRuntimeSessionSummary = { + liveTerminalCount: number + activeTaskCount: number +} + +type RuntimeSessionSummaryState = Pick< + AppState, + 'tabsByWorktree' | 'ptyIdsByTabId' | 'agentStatusByPaneKey' +> + +function getTabIdFromPaneKey(paneKey: string): string | null { + const separator = paneKey.indexOf(':') + return separator > 0 ? paneKey.slice(0, separator) : null +} + +export function getProjectRuntimeSessionSummary( + state: RuntimeSessionSummaryState, + repoId: string +): ProjectRuntimeSessionSummary { + const tabWorktreeIds = new Map() + const projectWorktreeIds = new Set() + let liveTerminalCount = 0 + + for (const [worktreeId, tabs] of Object.entries(state.tabsByWorktree)) { + if (getRepoIdFromWorktreeId(worktreeId) !== repoId) { + continue + } + projectWorktreeIds.add(worktreeId) + + for (const tab of tabs) { + tabWorktreeIds.set(tab.id, worktreeId) + const livePtyIds = new Set(state.ptyIdsByTabId[tab.id] ?? []) + if (tab.ptyId) { + livePtyIds.add(tab.ptyId) + } + liveTerminalCount += livePtyIds.size + } + } + + let activeTaskCount = 0 + for (const [paneKey, entry] of Object.entries(state.agentStatusByPaneKey)) { + if (entry.state === 'done') { + continue + } + const tabId = entry.tabId ?? getTabIdFromPaneKey(paneKey) + const worktreeId = entry.worktreeId ?? (tabId ? tabWorktreeIds.get(tabId) : null) + if (!worktreeId) { + continue + } + if (projectWorktreeIds.has(worktreeId) || getRepoIdFromWorktreeId(worktreeId) === repoId) { + activeTaskCount += 1 + } + } + + return { liveTerminalCount, activeTaskCount } +} diff --git a/src/renderer/src/components/settings/repository-search.ts b/src/renderer/src/components/settings/repository-search.ts index f03d91a1310..1b7a39e7f75 100644 --- a/src/renderer/src/components/settings/repository-search.ts +++ b/src/renderer/src/components/settings/repository-search.ts @@ -1,5 +1,6 @@ import type { Repo } from '../../../../shared/types' import { isFolderRepo } from '../../../../shared/repo-kind' +import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' import type { SettingsSearchEntry } from './settings-search' import { translate } from '@/i18n/i18n' import { translateSearchKeyword } from './settings-search-keywords' @@ -7,8 +8,20 @@ import { getRepositoryGitAuthorSearchEntries } from './repository-git-author-sea import { getRepositoryGitHooksSearchEntries } from './repository-git-hooks-search-entries' import { getRepositoryGitWorktreeSearchEntries } from './repository-git-worktree-search-entries' -export function getRepositoryPaneSearchEntries(repo: Repo): SettingsSearchEntry[] { +type RepositoryPaneSearchOptions = { + isLocalWindowsProject?: boolean + windowsRuntimeSupported?: boolean +} + +export function getRepositoryPaneSearchEntries( + repo: Repo, + options: RepositoryPaneSearchOptions = {} +): SettingsSearchEntry[] { const isFolder = isFolderRepo(repo) + const isLocalWindowsProject = + options.isLocalWindowsProject ?? + (Boolean(options.windowsRuntimeSupported) && + getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID) return [ { title: translate('auto.components.settings.repository.search.7e1e456a95', 'Display Name'), @@ -107,6 +120,48 @@ export function getRepositoryPaneSearchEntries(repo: Repo): SettingsSearchEntry[ } ] : []), + ...(isFolder || !isLocalWindowsProject + ? [] + : [ + { + title: translate( + 'auto.components.settings.repository.search.projectRuntime', + 'Project Runtime' + ), + description: translate( + 'auto.components.settings.repository.search.projectRuntimeDescription', + 'Choose whether this project runs on Windows or WSL.' + ), + keywords: [ + repo.displayName, + ...translateSearchKeyword( + 'auto.components.settings.repository.search.runtime', + 'runtime' + ), + ...translateSearchKeyword( + 'auto.components.settings.repository.search.execution', + 'execution' + ), + ...translateSearchKeyword( + 'auto.components.settings.repository.search.windowsHost', + 'windows host' + ), + ...translateSearchKeyword('auto.components.settings.repository.search.wsl', 'wsl'), + ...translateSearchKeyword( + 'auto.components.settings.repository.search.distro', + 'distro' + ), + ...translateSearchKeyword( + 'auto.components.settings.repository.search.agentRuntime', + 'agent runtime' + ), + ...translateSearchKeyword( + 'auto.components.settings.repository.search.skillRuntime', + 'skill runtime' + ) + ] + } + ]), ...(isFolder ? [] : getRepositoryGitWorktreeSearchEntries(repo)), { title: translate('auto.components.settings.repository.search.c5266c2c9d', 'Remove Project'), diff --git a/src/renderer/src/components/settings/terminal-search.test.ts b/src/renderer/src/components/settings/terminal-search.test.ts index 44ead63bb5a..7b1246694ed 100644 --- a/src/renderer/src/components/settings/terminal-search.test.ts +++ b/src/renderer/src/components/settings/terminal-search.test.ts @@ -15,6 +15,24 @@ describe('getTerminalPaneSearchEntries', () => { expect(entries.some((entry) => entry.title === 'PowerShell Version')).toBe(true) }) + it('keeps Windows host entries separate from Windows client entries', () => { + const entries = getTerminalPaneSearchEntries({ + isWindows: false, + isWindowsTerminalHost: true, + isMac: false + }) + + expect(entries.some((entry) => entry.title === 'Default Shell')).toBe(true) + expect(entries.some((entry) => entry.title === 'PowerShell Version')).toBe(true) + expect(entries.some((entry) => entry.title === 'Right-click to paste')).toBe(false) + }) + + it('omits legacy WSL distribution terminal settings on Windows', () => { + const entries = getTerminalPaneSearchEntries({ isWindows: true, isMac: false }) + expect(entries.some((entry) => entry.title === 'WSL Distribution')).toBe(false) + expect(matchesSettingsSearch('ubuntu distro', entries)).toBe(false) + }) + it('omits the Windows right-click setting elsewhere', () => { const entries = getTerminalPaneSearchEntries({ isWindows: false, isMac: false }) expect(entries.some((entry) => entry.title === 'Right-click to paste')).toBe(false) diff --git a/src/renderer/src/components/settings/terminal-search.ts b/src/renderer/src/components/settings/terminal-search.ts index 42c49695198..c12ef4113a4 100644 --- a/src/renderer/src/components/settings/terminal-search.ts +++ b/src/renderer/src/components/settings/terminal-search.ts @@ -20,7 +20,11 @@ import { getTerminalRenderingSearchEntries, getTerminalTypographySearchEntries } from './terminal-typography-search' -import { getTerminalWindowsSearchEntries } from './terminal-windows-search' +import { + getTerminalRightClickToPasteSearchEntry, + getTerminalWindowsPowershellImplementationSearchEntry, + getTerminalWindowsShellSearchEntry +} from './terminal-windows-search' import { getManageSessionsSearchEntries, getTerminalSetupScriptSearchEntries, @@ -91,15 +95,23 @@ export function getTerminalAppearanceSearchEntries( export function getTerminalPaneSearchEntries(platform: { isWindows: boolean + isWindowsTerminalHost?: boolean isMac: boolean }): SettingsSearchEntry[] { + const isWindowsTerminalHost = platform.isWindowsTerminalHost ?? platform.isWindows // Why: the settings search index must mirror the visible controls. Keeping // platform-only controls out of other platforms' search results prevents // users from landing on an option the UI intentionally hides. return [ ...getTerminalRenderingSearchEntries(), ...getTerminalPaneInteractionSearchEntries(), - ...(platform.isWindows ? getTerminalWindowsSearchEntries() : []), + ...(isWindowsTerminalHost + ? [ + ...getTerminalWindowsShellSearchEntry(), + ...getTerminalWindowsPowershellImplementationSearchEntry() + ] + : []), + ...(platform.isWindows ? getTerminalRightClickToPasteSearchEntry() : []), ...getTerminalSetupScriptSearchEntries(), ...getManageSessionsSearchEntries(), ...getTerminalAdvancedSearchEntries(), diff --git a/src/renderer/src/components/settings/terminal-windows-search.ts b/src/renderer/src/components/settings/terminal-windows-search.ts index c2192ae8dee..fae8b6fca9f 100644 --- a/src/renderer/src/components/settings/terminal-windows-search.ts +++ b/src/renderer/src/components/settings/terminal-windows-search.ts @@ -46,14 +46,6 @@ export const getTerminalWindowsShellSearchEntry = createLocalizedCatalog(() => [ 'auto.components.settings.terminal.windows.search.591912177b', 'git bash' ), - ...translateSearchKeyword( - 'auto.components.settings.terminal.windows.search.6e3adf4cba', - 'wsl' - ), - ...translateSearchKeyword( - 'auto.components.settings.terminal.windows.search.02c772582a', - 'linux' - ), ...translateSearchKeyword( 'auto.components.settings.terminal.windows.search.5a2db98d23', 'bash' @@ -61,10 +53,6 @@ export const getTerminalWindowsShellSearchEntry = createLocalizedCatalog(() => [ ...translateSearchKeyword( 'auto.components.settings.terminal.windows.search.07ec155fb6', 'bash.exe' - ), - ...translateSearchKeyword( - 'auto.components.settings.terminal.windows.search.4ee2579c32', - 'ubuntu' ) ] } @@ -117,57 +105,6 @@ export const getTerminalWindowsPowershellImplementationSearchEntry = createLocal } ]) -export const getTerminalWindowsWslDistroSearchEntry = createLocalizedCatalog(() => [ - { - title: translate( - 'auto.components.settings.terminal.windows.search.1f402b3651', - 'WSL Distribution' - ), - description: translate( - 'auto.components.settings.terminal.windows.search.978457945b', - 'Choose which WSL distribution new WSL terminals and local agent scans use.' - ), - keywords: [ - ...translateSearchKeyword( - 'auto.components.settings.terminal.windows.search.e7d2793b03', - 'terminal' - ), - ...translateSearchKeyword( - 'auto.components.settings.terminal.windows.search.28ff08ed35', - 'windows' - ), - ...translateSearchKeyword( - 'auto.components.settings.terminal.windows.search.6e3adf4cba', - 'wsl' - ), - ...translateSearchKeyword( - 'auto.components.settings.terminal.windows.search.02c772582a', - 'linux' - ), - ...translateSearchKeyword( - 'auto.components.settings.terminal.windows.search.2b4a340ce0', - 'distribution' - ), - ...translateSearchKeyword( - 'auto.components.settings.terminal.windows.search.5074ad8b5f', - 'distro' - ), - ...translateSearchKeyword( - 'auto.components.settings.terminal.windows.search.4ee2579c32', - 'ubuntu' - ), - ...translateSearchKeyword( - 'auto.components.settings.terminal.windows.search.fc564eadaf', - 'debian' - ), - ...translateSearchKeyword( - 'auto.components.settings.terminal.windows.search.04994f6929', - 'default' - ) - ] - } -]) - export const getTerminalRightClickToPasteSearchEntry = createLocalizedCatalog(() => [ { title: translate( @@ -205,7 +142,6 @@ export const getTerminalRightClickToPasteSearchEntry = createLocalizedCatalog(() export const getTerminalWindowsSearchEntries = createLocalizedCatalog((): SettingsSearchEntry[] => [ ...getTerminalWindowsShellSearchEntry(), - ...getTerminalWindowsWslDistroSearchEntry(), ...getTerminalWindowsPowershellImplementationSearchEntry(), ...getTerminalRightClickToPasteSearchEntry() ]) diff --git a/src/renderer/src/components/setup-guide/use-setup-guide-progress.ts b/src/renderer/src/components/setup-guide/use-setup-guide-progress.ts index 96e3673da59..fe54be5d7db 100644 --- a/src/renderer/src/components/setup-guide/use-setup-guide-progress.ts +++ b/src/renderer/src/components/setup-guide/use-setup-guide-progress.ts @@ -15,6 +15,7 @@ import { GLOBAL_AGENT_SKILL_SOURCE_KINDS, useInstalledAgentSkill } from '@/hooks/useInstalledAgentSkills' +import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime' import { getFeatureWallSetupProgress, type FeatureWallSetupProgress @@ -51,6 +52,7 @@ export function useSetupGuideProgress( const preflightStatusError = useAppStore((s) => s.preflightStatusError) const preflightStatusLoading = useAppStore((s) => s.preflightStatusLoading) const refreshPreflightStatus = useAppStore((s) => s.refreshPreflightStatus) + const activeSkillRuntime = useActiveProjectSkillRuntime() const linearStatus = useAppStore((s) => s.linearStatus) const linearStatusChecked = useAppStore((s) => s.linearStatusChecked) const linearStatusContextKey = useAppStore((s) => s.linearStatusContextKey) @@ -76,11 +78,13 @@ export function useSetupGuideProgress( const { installed: detectedBrowserUseSkillInstalled, loading: detectedBrowserUseSkillLoading } = useInstalledAgentSkill(ORCA_CLI_SKILL_NAME, { enabled: shouldRefreshCoreState, + discoveryTarget: activeSkillRuntime.discoveryTarget, sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS }) const { installed: computerUseSkillInstalled, loading: computerUseSkillLoading } = useInstalledAgentSkill(COMPUTER_USE_SKILL_NAME, { enabled: shouldRefreshCoreState, + discoveryTarget: activeSkillRuntime.discoveryTarget, sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS }) const { @@ -88,6 +92,7 @@ export function useSetupGuideProgress( loading: detectedOrchestrationSkillLoading } = useInstalledAgentSkill(ORCHESTRATION_SKILL_NAME, { enabled: shouldRefreshCoreState, + discoveryTarget: activeSkillRuntime.discoveryTarget, sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS }) const providerRuntimeContextKey = getProviderRuntimeContextKey(settings) diff --git a/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.test.tsx b/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.test.tsx index 6eeb461be7a..cb9f6887bf7 100644 --- a/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.test.tsx +++ b/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.test.tsx @@ -3,6 +3,7 @@ import { act, type ComponentProps, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' import type { CliInstallStatus } from '../../../../shared/cli-install-types' +import type { ProjectExecutionRuntimeResolution } from '../../../../shared/project-execution-runtime' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { LinearAgentSkillSetupPrompt, @@ -12,6 +13,29 @@ import { const HOST_DISMISS_STORAGE_KEY = 'orca.linearTicketsSkill.setupDismissed.host' const FEDORA_DISMISS_STORAGE_KEY = 'orca.linearTicketsSkill.setupDismissed.wsl.Fedora' +const projectHostRuntime: ProjectExecutionRuntimeResolution = { + status: 'resolved', + runtime: { + kind: 'windows-host', + hostPlatform: 'win32', + projectId: 'repo-1', + reason: 'project-override', + cacheKey: 'repo-1:windows-host' + } +} + +const projectWslRuntime: ProjectExecutionRuntimeResolution = { + status: 'resolved', + runtime: { + kind: 'wsl', + hostPlatform: 'wsl', + projectId: 'repo-1', + distro: 'Ubuntu', + reason: 'project-override', + cacheKey: 'repo-1:wsl:Ubuntu' + } +} + const mocks = vi.hoisted(() => ({ skillState: { installed: false, @@ -263,7 +287,6 @@ describe('LinearAgentSkillSetupPrompt', () => { localAgentRuntime: 'wsl', localAgentWslDistro: 'Fedora', terminalWindowsShell: 'wsl.exe', - terminalWindowsWslDistro: 'Ubuntu', activeRuntimeEnvironmentId: 'runtime-1' } }) @@ -299,7 +322,6 @@ describe('LinearAgentSkillSetupPrompt', () => { localAgentRuntime: 'wsl', localAgentWslDistro: 'Fedora', terminalWindowsShell: 'wsl.exe', - terminalWindowsWslDistro: 'Ubuntu', activeRuntimeEnvironmentId: null } }) @@ -387,6 +409,21 @@ describe('LinearAgentSkillSetupPrompt', () => { expect(mocks.getWslCliStatus).toHaveBeenCalledWith(undefined) }) + it('keeps stale terminal WSL settings on host when project runtime is absent', async () => { + await renderPrompt({ + linked: true, + remote: false, + currentPlatform: 'win32', + settings: { + terminalWindowsShell: 'wsl.exe', + activeRuntimeEnvironmentId: null + } + }) + + expect(mocks.getCliStatus).toHaveBeenCalled() + expect(mocks.getWslCliStatus).not.toHaveBeenCalled() + }) + it('opens the terminal setup panel in a dialog only after the user asks to set up', async () => { const rendered = await renderPrompt({ linked: true, remote: false }) @@ -798,6 +835,60 @@ describe('LinearAgentSkillSetupPrompt', () => { ) }) + it('uses project host runtime for skill discovery when legacy settings still point at WSL', async () => { + await renderPrompt({ + linked: true, + remote: false, + currentPlatform: 'win32', + projectRuntime: projectHostRuntime, + settings: { + localAgentRuntime: 'wsl', + localAgentWslDistro: 'Fedora', + terminalWindowsShell: 'wsl.exe', + activeRuntimeEnvironmentId: null + } + }) + + expect(mocks.useInstalledAgentSkill).toHaveBeenLastCalledWith( + 'linear-tickets', + expect.objectContaining({ + discoveryTarget: { projectRuntime: projectHostRuntime } + }) + ) + expect(mocks.getCliStatus).toHaveBeenCalled() + expect(mocks.getWslCliStatus).not.toHaveBeenCalled() + }) + + it('uses selected project WSL runtime for skill discovery and CLI status', async () => { + const rendered = await renderPrompt({ + linked: true, + remote: false, + currentPlatform: 'win32', + projectRuntime: projectWslRuntime, + settings: { + localAgentRuntime: 'host', + terminalWindowsShell: 'powershell.exe', + activeRuntimeEnvironmentId: null + } + }) + + expect(mocks.useInstalledAgentSkill).toHaveBeenLastCalledWith( + 'linear-tickets', + expect.objectContaining({ + discoveryTarget: { projectRuntime: projectWslRuntime } + }) + ) + expect(mocks.getWslCliStatus).toHaveBeenCalledWith({ distro: 'Ubuntu' }) + expect(mocks.getCliStatus).not.toHaveBeenCalled() + await act(async () => { + Array.from(rendered.querySelectorAll('button')) + .find((button) => button.textContent === 'Set up') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + await settleRender() + expect(mocks.panelProps.at(-1)?.command).toContain("wsl.exe -d 'Ubuntu'") + }) + it('uses remote-safe success copy for remote workspaces', async () => { await renderPrompt({ linked: true, remote: true, surface: 'modal' }) diff --git a/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.tsx b/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.tsx index 100bcc2cbca..4e45a8819f2 100644 --- a/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.tsx +++ b/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { RefreshCw, TicketCheck, X } from 'lucide-react' import type { CliInstallStatus } from '../../../../shared/cli-install-types' -import type { SkillDiscoveryTarget } from '../../../../shared/skills' +import type { ProjectExecutionRuntimeResolution } from '../../../../shared/project-execution-runtime' import { Button } from '@/components/ui/button' import { GLOBAL_AGENT_SKILL_SOURCE_KINDS, @@ -37,6 +37,8 @@ import { import { getCurrentPlatform, getLinearPromptAgentRuntime, + getLinearPromptSetupCheckIdentity, + getLinearPromptSkillDiscoveryTarget, getLinearPromptTerminalShellOverride, getLocalDismissStorageKey, readLocalDismissed, @@ -56,6 +58,7 @@ type LinearAgentSkillSetupPromptProps = { remote: boolean surface?: 'inline' | 'modal' settings?: LinearAgentSkillPromptSettings | null + projectRuntime?: ProjectExecutionRuntimeResolution currentPlatform?: NodeJS.Platform className?: string } @@ -67,6 +70,7 @@ export function LinearAgentSkillSetupPrompt({ remote, surface = 'inline', settings, + projectRuntime, currentPlatform = getCurrentPlatform(), className }: LinearAgentSkillSetupPromptProps): React.JSX.Element | null { @@ -76,28 +80,25 @@ export function LinearAgentSkillSetupPrompt({ const [setupCheckResult, setSetupCheckResult] = useState('idle') const [activeSetupCheckIdentity, setActiveSetupCheckIdentity] = useState(null) const agentRuntime = useMemo( - () => getLinearPromptAgentRuntime(settings, currentPlatform, remote), - [currentPlatform, remote, settings] + () => getLinearPromptAgentRuntime(settings, currentPlatform, remote, projectRuntime), + [currentPlatform, projectRuntime, remote, settings] ) const setupCheckIdentity = useMemo( () => - JSON.stringify({ + getLinearPromptSetupCheckIdentity({ remote, - runtime: agentRuntime.runtime, - wslDistro: agentRuntime.wslDistro ?? null, + runtime: agentRuntime, + projectRuntime, activeRuntimeEnvironmentId: settings?.activeRuntimeEnvironmentId ?? null }), - [agentRuntime.runtime, agentRuntime.wslDistro, remote, settings?.activeRuntimeEnvironmentId] + [agentRuntime, projectRuntime, remote, settings?.activeRuntimeEnvironmentId] ) const currentSetupCheckIdentityRef = useRef(setupCheckIdentity) const cliRefreshGenerationRef = useRef(0) currentSetupCheckIdentityRef.current = setupCheckIdentity - const skillDiscoveryTarget = useMemo( - () => - agentRuntime.runtime === 'wsl' - ? { runtime: 'wsl', wslDistro: agentRuntime.wslDistro } - : undefined, - [agentRuntime.runtime, agentRuntime.wslDistro] + const skillDiscoveryTarget = useMemo( + () => getLinearPromptSkillDiscoveryTarget(agentRuntime, projectRuntime), + [agentRuntime, projectRuntime] ) const localDismissStorageKey = getLocalDismissStorageKey(agentRuntime) const [localDismissed, setLocalDismissed] = useState(() => diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts index 93c6c2da8e1..0b733f057cd 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts @@ -11,7 +11,10 @@ vi.mock('@/lib/worktree-activation', () => ({ activateAndRevealFolderWorkspace: mocks.activateAndRevealFolderWorkspace })) -import { submitFolderWorkspaceCreate } from './folder-workspace-composer-submit' +import { + getFolderWorkspaceAgentLaunchPlatform, + submitFolderWorkspaceCreate +} from './folder-workspace-composer-submit' function makeProjectGroup(): ProjectGroup { return { @@ -220,6 +223,71 @@ describe('submitFolderWorkspaceCreate', () => { }) }) + it('quotes quick-agent startup for POSIX when the folder group is a local WSL UNC path', async () => { + const createFolderWorkspace = vi.fn(async () => makeFolderWorkspace()) + const projectGroup = { + ...makeProjectGroup(), + parentPath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\platform' + } + + expect(getFolderWorkspaceAgentLaunchPlatform(projectGroup)).toBe('linux') + + await submitFolderWorkspaceCreate({ + projectGroup, + name: 'WSL folder', + lastAutoName: '', + linkedWorkItem: null, + note: "Use Bob's POSIX startup", + quickAgent: 'claude', + autoRenameBranchFromWork: false, + agentCmdOverrides: {}, + createFolderWorkspace, + onOpenChange: vi.fn() + }) + + expect(mocks.activateAndRevealFolderWorkspace).toHaveBeenCalledWith( + 'folder-workspace-1', + expect.objectContaining({ + startup: expect.objectContaining({ + command: "claude 'Use Bob'\\''s POSIX startup'" + }) + }) + ) + }) + + it('quotes quick-agent startup for Windows when the remote folder group uses a Windows path', async () => { + const createFolderWorkspace = vi.fn(async () => makeFolderWorkspace()) + const projectGroup = { + ...makeProjectGroup(), + connectionId: 'ssh-windows', + parentPath: 'C:\\Users\\alice\\platform' + } + + expect(getFolderWorkspaceAgentLaunchPlatform(projectGroup)).toBe('win32') + + await submitFolderWorkspaceCreate({ + projectGroup, + name: 'Remote Windows folder', + lastAutoName: '', + linkedWorkItem: null, + note: "Use Bob's Windows startup", + quickAgent: 'claude', + autoRenameBranchFromWork: false, + agentCmdOverrides: {}, + createFolderWorkspace, + onOpenChange: vi.fn() + }) + + expect(mocks.activateAndRevealFolderWorkspace).toHaveBeenCalledWith( + 'folder-workspace-1', + expect.objectContaining({ + startup: expect.objectContaining({ + command: "claude 'Use Bob''s Windows startup'" + }) + }) + ) + }) + it('preserves SSH group ownership when creating and activating a folder workspace', async () => { const projectGroup = { ...makeProjectGroup(), diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts index 7ba174d9811..8620eefbb9f 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts @@ -9,7 +9,9 @@ import { buildAgentStartupPlan } from '@/lib/tui-agent-startup' import { tuiAgentToAgentKind } from '@/lib/telemetry' import { activateAndRevealFolderWorkspace } from '@/lib/worktree-activation' import { isWorkItemLookupText } from '@/lib/work-item-lookup-text' +import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path' import type { FolderWorkspace, ProjectGroup, TuiAgent } from '../../../../shared/types' +import { isWslUncPath } from '../../../../shared/wsl-paths' import type { LaunchSource } from '../../../../shared/telemetry-events' import { getLinkedItemDisplayName, @@ -43,6 +45,16 @@ type SubmitFolderWorkspaceCreateParams = { onOpenChange: (open: boolean) => void } +export function getFolderWorkspaceAgentLaunchPlatform( + projectGroup: Pick +): NodeJS.Platform { + const parentPath = projectGroup.parentPath?.trim() ?? '' + if (projectGroup.connectionId) { + return isWindowsAbsolutePathLike(parentPath) ? 'win32' : 'linux' + } + return parentPath && isWslUncPath(parentPath) ? 'linux' : CLIENT_PLATFORM +} + export async function submitFolderWorkspaceCreate({ projectGroup, name, @@ -110,7 +122,7 @@ export async function submitFolderWorkspaceCreate({ cmdOverrides: agentCmdOverrides ?? {}, agentArgs, agentEnv, - platform: CLIENT_PLATFORM, + platform: getFolderWorkspaceAgentLaunchPlatform(projectGroup), allowEmptyPromptLaunch: true }) : null diff --git a/src/renderer/src/components/sidebar/linear-agent-skill-runtime.ts b/src/renderer/src/components/sidebar/linear-agent-skill-runtime.ts index db6f3e383fc..69e92589cdc 100644 --- a/src/renderer/src/components/sidebar/linear-agent-skill-runtime.ts +++ b/src/renderer/src/components/sidebar/linear-agent-skill-runtime.ts @@ -1,4 +1,6 @@ import type { GlobalSettings } from '../../../../shared/types' +import type { ProjectExecutionRuntimeResolution } from '../../../../shared/project-execution-runtime' +import type { SkillDiscoveryTarget } from '../../../../shared/skills' import { translate } from '@/i18n/i18n' import type { LocalAgentRuntime } from '../settings/CliSkillRuntimeSetup' @@ -9,7 +11,6 @@ export type LinearAgentSkillPromptSettings = Pick< | 'localAgentRuntime' | 'localAgentWslDistro' | 'terminalWindowsShell' - | 'terminalWindowsWslDistro' | 'activeRuntimeEnvironmentId' > @@ -23,7 +24,8 @@ export function getCurrentPlatform(): NodeJS.Platform { export function getLinearPromptAgentRuntime( settings: LinearAgentSkillPromptSettings | null | undefined, currentPlatform: NodeJS.Platform, - remote: boolean + remote: boolean, + projectRuntime?: ProjectExecutionRuntimeResolution ): LocalAgentRuntime { if (remote) { // Why: this prompt opens a local terminal; remote environments need their @@ -33,11 +35,13 @@ export function getLinearPromptAgentRuntime( label: currentPlatform === 'win32' ? 'Windows' : 'This device' } } - const selectedRuntime = - settings?.localAgentRuntime ?? (settings?.terminalWindowsShell === 'wsl.exe' ? 'wsl' : 'host') + const resolvedProjectRuntime = getProjectAgentRuntime(projectRuntime, currentPlatform) + if (resolvedProjectRuntime) { + return resolvedProjectRuntime + } + const selectedRuntime = settings?.localAgentRuntime ?? 'host' if (currentPlatform === 'win32' && selectedRuntime === 'wsl') { - const selectedDistro = - settings?.localAgentWslDistro?.trim() || settings?.terminalWindowsWslDistro?.trim() || null + const selectedDistro = settings?.localAgentWslDistro?.trim() || null return { runtime: 'wsl', wslDistro: selectedDistro, @@ -52,6 +56,37 @@ export function getLinearPromptAgentRuntime( } } +function getProjectAgentRuntime( + projectRuntime: ProjectExecutionRuntimeResolution | undefined, + currentPlatform: NodeJS.Platform +): LocalAgentRuntime | null { + if (!projectRuntime) { + return null + } + if (projectRuntime.status === 'repair-required') { + // Why: a repair state still owns the project runtime; falling back to host + // here would mix skill setup state between Windows and WSL. + return getWslAgentRuntime(projectRuntime.repair.preferredRuntime.distro) + } + if (projectRuntime.runtime.kind === 'wsl') { + return getWslAgentRuntime(projectRuntime.runtime.distro) + } + return { + runtime: 'host', + label: currentPlatform === 'win32' ? 'Windows' : 'This device' + } +} + +function getWslAgentRuntime(distro: string | null): LocalAgentRuntime { + return { + runtime: 'wsl', + wslDistro: distro, + label: distro + ? `WSL ${distro}` + : translate('auto.components.sidebar.LinearAgentSkillSetupPrompt.wslLabel', 'WSL default') + } +} + export function getLinearPromptTerminalShellOverride( currentPlatform: NodeJS.Platform, settings: LinearAgentSkillPromptSettings | null | undefined, @@ -66,6 +101,31 @@ export function getLinearPromptTerminalShellOverride( return settings?.terminalWindowsShell?.toLowerCase() === 'wsl.exe' ? 'powershell.exe' : undefined } +export function getLinearPromptSetupCheckIdentity(args: { + remote: boolean + runtime: LocalAgentRuntime + projectRuntime?: ProjectExecutionRuntimeResolution + activeRuntimeEnvironmentId?: string | null +}): string { + return JSON.stringify({ + remote: args.remote, + runtime: args.runtime.runtime, + wslDistro: args.runtime.wslDistro ?? null, + projectRuntime: getProjectRuntimeIdentity(args.projectRuntime), + activeRuntimeEnvironmentId: args.activeRuntimeEnvironmentId ?? null + }) +} + +export function getLinearPromptSkillDiscoveryTarget( + runtime: LocalAgentRuntime, + projectRuntime?: ProjectExecutionRuntimeResolution +): SkillDiscoveryTarget | undefined { + if (projectRuntime) { + return { projectRuntime } + } + return runtime.runtime === 'wsl' ? { runtime: 'wsl', wslDistro: runtime.wslDistro } : undefined +} + export function getLocalDismissStorageKey(runtime: LocalAgentRuntime): string { if (runtime.runtime !== 'wsl') { return `${LOCAL_DISMISS_STORAGE_KEY_PREFIX}.host` @@ -79,3 +139,14 @@ export function readLocalDismissed(storageKey: string): boolean { } return localStorage.getItem(storageKey) === '1' } + +function getProjectRuntimeIdentity( + projectRuntime: ProjectExecutionRuntimeResolution | undefined +): string | null { + if (!projectRuntime) { + return null + } + return projectRuntime.status === 'resolved' + ? projectRuntime.runtime.cacheKey + : projectRuntime.repair.cacheKey +} diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index a3061956b4d..b67aa4cfa7d 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -169,12 +169,11 @@ function toCodexStatusRuntimeTarget( return { runtime: 'host', wslDistro: null } } -function getStatusBarPreferredWslDistro( +export function getStatusBarPreferredWslDistro( settings: GlobalSettings | null | undefined, wslDistros: string[] ): string | null { - const configuredDistro = - settings?.localAccountWslDistro?.trim() || settings?.terminalWindowsWslDistro?.trim() || null + const configuredDistro = settings?.localAccountWslDistro?.trim() || null if (configuredDistro) { return configuredDistro } diff --git a/src/renderer/src/components/status-bar/status-bar-runtime-groups.test.ts b/src/renderer/src/components/status-bar/status-bar-runtime-groups.test.ts index 665273236b7..652b7f7448d 100644 --- a/src/renderer/src/components/status-bar/status-bar-runtime-groups.test.ts +++ b/src/renderer/src/components/status-bar/status-bar-runtime-groups.test.ts @@ -1,9 +1,14 @@ import { describe, expect, it } from 'vitest' import type { ClaudeRateLimitAccountsState, - CodexRateLimitAccountsState + CodexRateLimitAccountsState, + GlobalSettings } from '../../../../shared/types' -import { buildClaudeStatusSwitchGroups, buildCodexStatusSwitchGroups } from './StatusBar' +import { + buildClaudeStatusSwitchGroups, + buildCodexStatusSwitchGroups, + getStatusBarPreferredWslDistro +} from './StatusBar' const hostLabel = navigator.userAgent.includes('Windows') ? 'Windows' : 'This device' @@ -104,4 +109,28 @@ describe('status bar runtime switch groups', () => { { key: 'wsl:Ubuntu', label: 'WSL Ubuntu', targets: ['System default'] } ]) }) + + it('ignores stale terminal WSL distro for account runtime fallback groups', () => { + expect( + getStatusBarPreferredWslDistro( + { + localAccountWslDistro: null, + terminalWindowsWslDistro: 'Debian' + } as GlobalSettings, + ['Ubuntu'] + ) + ).toBe('Ubuntu') + }) + + it('uses the account WSL distro before single-distro fallback groups', () => { + expect( + getStatusBarPreferredWslDistro( + { + localAccountWslDistro: 'Fedora', + terminalWindowsWslDistro: 'Debian' + } as GlobalSettings, + ['Ubuntu'] + ) + ).toBe('Fedora') + }) }) diff --git a/src/renderer/src/components/tab-bar/BrowserTab.test.tsx b/src/renderer/src/components/tab-bar/BrowserTab.test.tsx index 17bec1b0c69..e317d7c24af 100644 --- a/src/renderer/src/components/tab-bar/BrowserTab.test.tsx +++ b/src/renderer/src/components/tab-bar/BrowserTab.test.tsx @@ -195,7 +195,7 @@ async function renderExpandedBrowserTab(tab: BrowserTabState): Promise return expandNode(await renderBrowserTab(tab)) } -describe('BrowserTab favicon', () => { +describe('BrowserTab favicon', { timeout: 10_000 }, () => { beforeEach(() => { reactHookRuntime.states = [] reactHookRuntime.index = 0 diff --git a/src/renderer/src/components/tab-bar/TabBar.tsx b/src/renderer/src/components/tab-bar/TabBar.tsx index b2969d28c0c..1619f237ac2 100644 --- a/src/renderer/src/components/tab-bar/TabBar.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.tsx @@ -23,6 +23,7 @@ import type { TuiAgent, WorkspaceVisibleTabType } from '../../../../shared/types' +import type { ProjectExecutionRuntimeResolution } from '../../../../shared/project-execution-runtime' import { resolveTerminalTabTitle } from '../../../../shared/tab-title-resolution' import { useAppStore } from '../../store' import { buildStatusMap } from '../right-sidebar/status-display' @@ -49,6 +50,7 @@ import { } from '@/lib/windows-terminal-capabilities' import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { useShortcutLabel } from '@/hooks/useShortcutLabel' import { type BuiltInWindowsTerminalShell, @@ -82,6 +84,18 @@ const EMPTY_AGENT_CMD_OVERRIDES: Partial> = {} const EMPTY_UNIFIED_TABS: readonly Tab[] = [] const AGENT_DETECTION_LOCAL_TARGET_KEY = 'local' +function getProjectRuntimeShellMenuMode( + projectRuntime: ProjectExecutionRuntimeResolution | undefined +): 'host' | 'wsl' | null { + if (!projectRuntime) { + return null + } + if (projectRuntime.status === 'repair-required') { + return 'wsl' + } + return projectRuntime.runtime.kind === 'wsl' ? 'wsl' : 'host' +} + type TabBarProps = { tabs: (TerminalTab & { unifiedTabId?: string })[] activeTabId: string | null @@ -282,6 +296,12 @@ function TabBarInner({ const defaultWindowsPowerShellImplementation = useAppStore( (s) => s.settings?.terminalWindowsPowerShellImplementation ?? 'auto' ) + const activeRepoId = useAppStore((s) => s.activeRepoId) + const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) + const projects = useAppStore((s) => s.projects) + const repos = useAppStore((s) => s.repos) + const settings = useAppStore((s) => s.settings) + const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) // Why: probe Windows shell capabilities on the host that owns this worktree, so // the offered shells match the host that actually runs the terminal. const activeRuntimeEnvironmentId = useAppStore( @@ -361,6 +381,45 @@ function TabBarInner({ const shouldShowWindowsShellMenu = (isWindows || windowsTerminalCapabilities.hostPlatform === 'win32') && !worktreeHasRemoteConnection + const localProjectRuntime = useMemo(() => { + if (!shouldShowWindowsShellMenu || activeRuntimeEnvironmentId?.trim()) { + return undefined + } + return getLocalProjectExecutionRuntimeContext( + { + activeRepoId, + activeWorktreeId, + projects, + repos, + settings, + worktreesByRepo + }, + worktreeId, + 'win32', + { + wslAvailable: windowsTerminalCapabilities.isLoading + ? undefined + : windowsTerminalCapabilities.wslAvailable, + availableWslDistros: windowsTerminalCapabilities.isLoading + ? null + : windowsTerminalCapabilities.wslDistros + } + ) + }, [ + activeRepoId, + activeRuntimeEnvironmentId, + activeWorktreeId, + projects, + repos, + settings, + shouldShowWindowsShellMenu, + windowsTerminalCapabilities.isLoading, + windowsTerminalCapabilities.wslAvailable, + windowsTerminalCapabilities.wslDistros, + worktreeId, + worktreesByRepo + ]) + const projectRuntimeShellMenuMode = getProjectRuntimeShellMenuMode(localProjectRuntime) const resolvedGroupId = groupId ?? activeGroupIdForWorktree ?? worktreeId const statusByRelativePath = useMemo(() => buildStatusMap(gitStatusEntries), [gitStatusEntries]) @@ -437,35 +496,39 @@ function TabBarInner({ if (!shouldShowWindowsShellMenu || !onNewTerminalWithShell) { return undefined } + const includeHostShells = projectRuntimeShellMenuMode !== 'wsl' + const includeWslShell = projectRuntimeShellMenuMode !== 'host' const allShells: { label: string shell: BuiltInWindowsTerminalShell - }[] = [ - { - label: translate('auto.components.tab.bar.TabBar.2148f65e04', 'PowerShell'), - shell: 'powershell.exe' - }, - { - label: translate('auto.components.tab.bar.TabBar.1a8af49530', 'CMD Prompt'), - shell: 'cmd.exe' - }, - ...(windowsTerminalCapabilities.gitBashAvailable - ? ([ - { - label: translate('auto.components.tab.bar.TabBar.efb33546ff', 'Git Bash'), - shell: WINDOWS_GIT_BASH_SHELL - } - ] as const) - : []), - ...(windowsTerminalCapabilities.wslAvailable - ? ([ - { - label: translate('auto.components.tab.bar.TabBar.d1afac112b', 'WSL'), - shell: 'wsl.exe' - } - ] as const) - : []) - ] + }[] = [] + if (includeHostShells) { + allShells.push( + { + label: translate('auto.components.tab.bar.TabBar.2148f65e04', 'PowerShell'), + shell: 'powershell.exe' + }, + { + label: translate('auto.components.tab.bar.TabBar.1a8af49530', 'CMD Prompt'), + shell: 'cmd.exe' + } + ) + if (windowsTerminalCapabilities.gitBashAvailable) { + allShells.push({ + label: translate('auto.components.tab.bar.TabBar.efb33546ff', 'Git Bash'), + shell: WINDOWS_GIT_BASH_SHELL + }) + } + } + if (includeWslShell && windowsTerminalCapabilities.wslAvailable) { + allShells.push({ + label: translate('auto.components.tab.bar.TabBar.d1afac112b', 'WSL'), + shell: 'wsl.exe' + }) + } + if (allShells.length === 0) { + return undefined + } const defaultEntry = allShells.find((shell) => shell.shell === defaultWindowsShell) ?? allShells[0] const orderedShells = [ @@ -476,6 +539,7 @@ function TabBarInner({ }, [ defaultWindowsShell, onNewTerminalWithShell, + projectRuntimeShellMenuMode, shouldShowWindowsShellMenu, windowsTerminalCapabilities.gitBashAvailable, windowsTerminalCapabilities.wslAvailable diff --git a/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts b/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts index 5cf3f9923be..815ce1f73e1 100644 --- a/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts +++ b/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts @@ -4,11 +4,26 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const appStoreSnapshot: { + activeRepoId: string | null activeTabId: string | null activeTabType: 'terminal' | 'editor' | 'browser' | 'simulator' | null activeRuntimeEnvironmentId: string | null + activeWorktreeId: string | null + projects: { + id: string + localWindowsRuntimePreference?: + | { kind: 'inherit-global' | 'windows-host' } + | { + kind: 'wsl' + distro: string + } + sourceRepoIds?: string[] + }[] repos: { id: string; connectionId?: string | null }[] - worktreesByRepo: Record + worktreesByRepo: Record< + string, + { id: string; repoId: string; path?: string; projectId?: string }[] + > unifiedTabsByWorktree: Record activeGroupIdByWorktree: Record detectedAgentIds: string[] | null @@ -16,9 +31,12 @@ const appStoreSnapshot: { isDetectingAgents: boolean isDetectingRemoteAgents: Record } = { + activeRepoId: null, activeTabId: null, activeTabType: null, activeRuntimeEnvironmentId: null, + activeWorktreeId: null, + projects: [], repos: [], worktreesByRepo: {}, unifiedTabsByWorktree: {}, @@ -34,11 +52,14 @@ const unpinTabMock: (tabId: string) => void = vi.fn() const useAppStoreMock = vi.fn( ( selector: (state: { + activeRepoId: string | null activeTabId: string | null activeTabType: 'terminal' | 'editor' | 'browser' | 'simulator' | null + activeWorktreeId: string | null gitStatusByWorktree: Record + projects: typeof appStoreSnapshot.projects repos: { id: string; connectionId?: string | null }[] - worktreesByRepo: Record + worktreesByRepo: typeof appStoreSnapshot.worktreesByRepo unifiedTabsByWorktree: Record activeGroupIdByWorktree: Record detectedAgentIds: string[] | null @@ -51,13 +72,17 @@ const useAppStoreMock = vi.fn( terminalWindowsShell: 'powershell.exe' | 'cmd.exe' | 'wsl.exe' | 'git-bash' terminalWindowsPowerShellImplementation: 'auto' | 'powershell.exe' | 'pwsh.exe' activeRuntimeEnvironmentId: string | null + localWindowsRuntimeDefault: { kind: 'windows-host' } | { kind: 'wsl'; distro: string } } }) => unknown ) => selector({ + activeRepoId: appStoreSnapshot.activeRepoId, activeTabId: appStoreSnapshot.activeTabId, activeTabType: appStoreSnapshot.activeTabType, + activeWorktreeId: appStoreSnapshot.activeWorktreeId, gitStatusByWorktree: {}, + projects: appStoreSnapshot.projects, repos: appStoreSnapshot.repos, worktreesByRepo: appStoreSnapshot.worktreesByRepo, unifiedTabsByWorktree: appStoreSnapshot.unifiedTabsByWorktree, @@ -71,7 +96,8 @@ const useAppStoreMock = vi.fn( settings: { terminalWindowsShell: 'powershell.exe', terminalWindowsPowerShellImplementation: 'pwsh.exe', - activeRuntimeEnvironmentId: appStoreSnapshot.activeRuntimeEnvironmentId + activeRuntimeEnvironmentId: appStoreSnapshot.activeRuntimeEnvironmentId, + localWindowsRuntimeDefault: { kind: 'windows-host' } } }) ) @@ -117,9 +143,12 @@ vi.mock('@dnd-kit/sortable', () => ({ const useAppStoreExport = (selector: Parameters[0]): unknown => useAppStoreMock(selector) useAppStoreExport.getState = vi.fn(() => ({ + activeRepoId: appStoreSnapshot.activeRepoId, activeTabId: appStoreSnapshot.activeTabId, activeTabType: appStoreSnapshot.activeTabType, + activeWorktreeId: appStoreSnapshot.activeWorktreeId, gitStatusByWorktree: {}, + projects: appStoreSnapshot.projects, repos: appStoreSnapshot.repos, worktreesByRepo: appStoreSnapshot.worktreesByRepo, unifiedTabsByWorktree: appStoreSnapshot.unifiedTabsByWorktree, @@ -133,7 +162,8 @@ useAppStoreExport.getState = vi.fn(() => ({ settings: { terminalWindowsShell: 'powershell.exe', terminalWindowsPowerShellImplementation: 'pwsh.exe', - activeRuntimeEnvironmentId: appStoreSnapshot.activeRuntimeEnvironmentId + activeRuntimeEnvironmentId: appStoreSnapshot.activeRuntimeEnvironmentId, + localWindowsRuntimeDefault: { kind: 'windows-host' } } })) @@ -281,9 +311,12 @@ describe('TabBar PowerShell launch wiring', () => { beforeEach(() => { vi.clearAllMocks() vi.resetModules() + appStoreSnapshot.activeRepoId = null appStoreSnapshot.activeTabId = null appStoreSnapshot.activeTabType = null appStoreSnapshot.activeRuntimeEnvironmentId = null + appStoreSnapshot.activeWorktreeId = null + appStoreSnapshot.projects = [] appStoreSnapshot.repos = [] appStoreSnapshot.worktreesByRepo = {} appStoreSnapshot.unifiedTabsByWorktree = {} @@ -347,7 +380,7 @@ describe('TabBar PowerShell launch wiring', () => { expect(onNewTerminalWithShell).toHaveBeenCalledWith('pwsh.exe') }) - it('shows the WSL terminal row when shared Windows capabilities report WSL', async () => { + it('hides the WSL terminal row for local host-runtime projects', async () => { vi.stubGlobal('window', { api: { wsl: { @@ -389,6 +422,75 @@ describe('TabBar PowerShell launch wiring', () => { onTogglePaneExpand: () => {} }) + expect( + findDropdownMenuItemByText(expandNode(element), 'New Terminal: PowerShell') + ).not.toBeNull() + expect(findDropdownMenuItemByText(expandNode(element), 'New Terminal: WSL')).toBeNull() + }) + + it('shows only the WSL terminal row for local WSL-runtime projects', async () => { + appStoreSnapshot.activeRepoId = 'repo-1' + appStoreSnapshot.projects = [ + { + id: 'project-1', + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }, + sourceRepoIds: ['repo-1'] + } + ] + appStoreSnapshot.repos = [{ id: 'repo-1' }] + appStoreSnapshot.worktreesByRepo = { + 'repo-1': [ + { + id: 'wt-1', + repoId: 'repo-1', + path: 'C:\\repo', + projectId: 'project-1' + } + ] + } + vi.stubGlobal('window', { + api: { + wsl: { + isAvailable: vi.fn().mockResolvedValue(true), + listDistros: vi.fn().mockResolvedValue(['Ubuntu']) + }, + pwsh: { isAvailable: vi.fn().mockResolvedValue(true) }, + gitBash: { isAvailable: vi.fn().mockResolvedValue(true) }, + runtime: { getStatus: vi.fn().mockResolvedValue({ hostPlatform: 'win32' }) } + } + }) + const capabilities = await import('@/lib/windows-terminal-capabilities') + await capabilities.loadWindowsTerminalCapabilities() + + const tabBarModule = await import('./TabBar') + const candidate = tabBarModule.default ?? tabBarModule + const TabBar = + typeof candidate === 'function' + ? candidate + : typeof (candidate as { type?: unknown }).type === 'function' + ? (candidate as { type: (props: Record) => unknown }).type + : null + expect(TabBar).not.toBeNull() + + const element = TabBar!({ + tabs: [], + activeTabId: null, + worktreeId: 'wt-1', + expandedPaneByTabId: {}, + onActivate: () => {}, + onClose: () => {}, + onCloseOthers: () => {}, + onCloseToRight: () => {}, + onNewTerminalTab: () => {}, + onNewTerminalWithShell: () => {}, + onNewBrowserTab: () => {}, + onSetCustomTitle: () => {}, + onSetTabColor: () => {}, + onTogglePaneExpand: () => {} + }) + + expect(findDropdownMenuItemByText(expandNode(element), 'New Terminal: PowerShell')).toBeNull() + expect(findDropdownMenuItemByText(expandNode(element), 'New Terminal: Git Bash')).toBeNull() expect(findDropdownMenuItemByText(expandNode(element), 'New Terminal: WSL')).not.toBeNull() }) diff --git a/src/renderer/src/components/task-page-source-switch-boundary.test.ts b/src/renderer/src/components/task-page-source-switch-boundary.test.ts index b2dee55ce00..a694b99c44c 100644 --- a/src/renderer/src/components/task-page-source-switch-boundary.test.ts +++ b/src/renderer/src/components/task-page-source-switch-boundary.test.ts @@ -16,15 +16,16 @@ describe('TaskPage source switching host boundary', () => { it('renders GitHub item details from the task-detail page owner only', () => { const detailSection = sourceBetween( TASK_PAGE_SOURCE, - "{taskSource === 'github' && dialogWorkItem ?", + '', ' cacheTimerByKey: Record settings: { @@ -64,6 +71,9 @@ type StoreState = { promptCacheTimerEnabled?: boolean activeRuntimeEnvironmentId?: string | null experimentalTerminalAttention?: boolean + terminalWindowsShell?: string + terminalWindowsWslDistro?: string | null + localWindowsRuntimeDefault?: { kind: 'windows-host' } | { kind: 'wsl'; distro: string | null } notifications?: { enabled?: boolean agentTaskComplete?: boolean @@ -471,6 +481,7 @@ describe('connectPanePty', () => { repo1: [{ id: 'wt-1', repoId: 'repo1', path: '/tmp/wt-1', displayName: 'feat/notis' }] }, repos: [{ id: 'repo1', connectionId: null, displayName: 'orca' }], + projects: [], sshConnectionStates: new Map(), cacheTimerByKey: {}, settings: { promptCacheTimerEnabled: true, experimentalTerminalAttention: true }, @@ -523,6 +534,9 @@ describe('connectPanePty', () => { settlePaneSerializer: vi.fn().mockResolvedValue(undefined), clearPendingPaneSerializer: vi.fn().mockResolvedValue(undefined) }, + platform: { + get: vi.fn(() => ({ platform: 'win32', osRelease: '10.0.26100' })) + }, notifications: { dispatch: vi.fn().mockResolvedValue({ delivered: true }), playSound: vi.fn().mockResolvedValue({ played: true }) @@ -584,6 +598,36 @@ describe('connectPanePty', () => { logSpy.mockRestore() }) + it('threads the resolved local project runtime into IPC terminal transport options', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + settings: { + ...mockStoreState.settings, + terminalWindowsShell: 'wsl.exe', + terminalWindowsWslDistro: 'Debian', + localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Debian' } + }, + projects: [{ id: 'repo1', localWindowsRuntimePreference: { kind: 'windows-host' } }] + } + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks() + + expect(createdTransportOptions[0]?.projectRuntime).toEqual({ + status: 'resolved', + runtime: { + kind: 'windows-host', + hostPlatform: 'win32', + projectId: 'repo1', + reason: 'project-override', + cacheKey: 'repo1:windows-host' + } + }) + }) + it('observes live terminal GitHub PR URLs before agent completion', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport() @@ -3103,6 +3147,77 @@ describe('connectPanePty', () => { ) }) + it('uses WSL quoting for cold-restored agent resume in Windows-path WSL projects', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('fresh-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { + id: 'fresh-pty', + coldRestore: { scrollback: 'cold-payload', cwd: 'C:\\tmp\\wt-1' } + } + } + return 'fresh-pty' + }) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty' }] + }, + settings: { + ...mockStoreState.settings, + agentCmdOverrides: {}, + localWindowsRuntimeDefault: { kind: 'windows-host' } + }, + projects: [ + { + id: 'repo1', + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + } + ], + worktreesByRepo: { + repo1: [ + { + id: 'wt-1', + repoId: 'repo1', + path: 'C:\\tmp\\wt-1', + displayName: 'feat/notis' + } + ] + }, + agentStatusByPaneKey: { + [paneKey]: { + state: 'working', + prompt: 'finish the task', + agentType: 'codex', + paneKey, + updatedAt: 1, + stateStartedAt: 1, + stateHistory: [], + providerSession: { key: 'session_id', id: "codex-session-1's" } + } + } + } as StoreState + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'lost-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + await new Promise((resolve) => setTimeout(resolve, 70)) + + expect(pane.terminal.write).toHaveBeenCalledWith('cold-payload', expect.any(Function)) + expect(transport.sendInput).toHaveBeenCalledWith( + "codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'codex-session-1'\\''s'\r" + ) + }) + it('resumes from the quit-captured sleeping record when cold-restoring after an app restart', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('fresh-pty') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 83483b02a29..d309630633e 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -14,6 +14,11 @@ import type { PtyBufferSnapshot, PtyConnectResult } from './pty-transport' import { createIpcPtyTransport } from './pty-transport' import { createRemoteRuntimePtyTransport } from './remote-runtime-pty-transport' import { getConnectionId } from '@/lib/connection-context' +import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' +import { + getCachedWindowsTerminalCapabilities, + hasCachedWindowsTerminalCapabilities +} from '@/lib/windows-terminal-capabilities' import { shouldSeedCacheTimerOnInitialTitle } from './cache-timer-seeding' import type { PtyConnectionDeps } from './pty-connection-types' import { safeFit } from '@/lib/pane-manager/pane-tree-ops' @@ -1631,6 +1636,16 @@ export function connectPanePty( : null) ?? (tab?.ptyId ? getRemoteRuntimePtyEnvironmentId(tab.ptyId) : null) const runtimeEnvironmentId = remoteRuntimeOwnerForTransport ?? getRuntimeEnvironmentIdForWorktree(state, deps.worktreeId) + const localWindowsTerminalCapabilities = hasCachedWindowsTerminalCapabilities() + ? getCachedWindowsTerminalCapabilities() + : null + const projectRuntime = + !connectionId && runtimeEnvironmentId === null + ? getLocalProjectExecutionRuntimeContext(state, deps.worktreeId, undefined, { + wslAvailable: localWindowsTerminalCapabilities?.wslAvailable, + availableWslDistros: localWindowsTerminalCapabilities?.wslDistros ?? null + }) + : undefined const shouldOwnAgentStatusInRenderer = runtimeEnvironmentId !== null const shouldDeliverStartupViaTerminalPaste = paneStartup?.delivery === 'terminal-paste' const hadExistingPaneTransportAtConnect = deps.paneTransportsRef.current.size > 0 @@ -1660,6 +1675,7 @@ export function connectPanePty( leafId: pane.leafId, activate: deps.isActiveRef.current && deps.isVisibleRef.current, ...(shellOverride ? { shellOverride } : {}), + ...(projectRuntime ? { projectRuntime } : {}), ...(paneStartup?.telemetry ? { telemetry: paneStartup.telemetry } : {}), onPtyExit: onExit, onTitleChange, @@ -1981,6 +1997,12 @@ export function connectPanePty( deps.onShowSessionRestoredBanner(pane.id) } const getColdRestoreAgentResumePlatform = (): NodeJS.Platform => { + if (projectRuntime?.status === 'repair-required') { + return projectRuntime.repair.preferredRuntime.kind === 'wsl' ? 'linux' : CLIENT_PLATFORM + } + if (projectRuntime?.status === 'resolved' && projectRuntime.runtime.kind === 'wsl') { + return 'linux' + } if (connectionId || (worktree?.path && isWslUncPath(worktree.path))) { return 'linux' } diff --git a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts index 656657aab2e..63044ad5159 100644 --- a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts +++ b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts @@ -7,6 +7,7 @@ */ import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types' import type { EventProps } from '../../../../shared/telemetry-events' +import type { ProjectExecutionRuntimeResolution } from '../../../../shared/project-execution-runtime' import { ackPtyData, exposeE2eTerminalPtyAckGate } from './terminal-pty-ack-gate' // ── Singleton PTY event dispatcher ─────────────────────────────────── @@ -365,6 +366,7 @@ export type IpcPtyTransportOptions = { activate?: boolean /** Why: mirrors PtySpawnOptions.shellOverride — see types.ts for rationale. */ shellOverride?: string + projectRuntime?: ProjectExecutionRuntimeResolution /** Telemetry metadata for the `agent_started` event. Forwarded verbatim * to `pty:spawn` so main can fire the event after confirmed launch. The * IPC handler re-validates the schema; this type is the renderer-side diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index 93e28bed670..d07b9de9f32 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -426,6 +426,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra tabId, leafId, shellOverride, + projectRuntime, telemetry, onPtyExit, onTitleChange, @@ -548,6 +549,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra ...(tabId ? { tabId } : {}), ...(leafId ? { leafId } : {}), ...(shellOverride ? { shellOverride } : {}), + ...(projectRuntime ? { projectRuntime } : {}), ...(telemetry ? { telemetry } : {}) }) const spawnResult = result as PtyConnectResult & { isReattach?: boolean } diff --git a/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts b/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts index ff88fe6807a..88584e8a653 100644 --- a/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts @@ -16,7 +16,21 @@ const mockMarkTrusted = vi.fn(async () => undefined) const LEAF_ID = '11111111-1111-4111-8111-111111111111' const store = { + activeRepoId: 'repo-1', + activeWorktreeId: 'wt-1', + projects: [] as { + id: string + sourceRepoIds: string[] + localWindowsRuntimePreference?: { kind: 'windows-host' } | { kind: 'wsl'; distro: string } + }[], repos: [] as { id: string; kind?: 'git' | 'folder'; connectionId?: string | null }[], + settings: {} as { + localWindowsRuntimeDefault?: { kind: 'windows-host' } | { kind: 'wsl'; distro: string } + }, + worktreesByRepo: {} as Record< + string, + { id: string; repoId: string; path?: string; projectId?: string }[] + >, agentStatusByPaneKey: {} as Record, tabsByWorktree: {} as Record, getKnownWorktreeById: vi.fn(), @@ -56,7 +70,19 @@ function makePane(capturedText: string): ManagedPane { describe('forkAgentSessionFromPane', () => { beforeEach(() => { vi.clearAllMocks() + store.activeRepoId = 'repo-1' + store.activeWorktreeId = 'wt-1' + store.projects = [ + { + id: 'repo-1', + sourceRepoIds: ['repo-1'] + } + ] store.repos = [{ id: 'repo-1', kind: 'git' }] + store.settings = { localWindowsRuntimeDefault: { kind: 'windows-host' } } + store.worktreesByRepo = { + 'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: 'C:\\repo', projectId: 'repo-1' }] + } store.agentStatusByPaneKey = {} store.tabsByWorktree = { 'wt-1': [{ id: 'tab-1' }] } store.getKnownWorktreeById.mockReturnValue({ @@ -84,6 +110,9 @@ describe('forkAgentSessionFromPane', () => { }, agentTrust: { markTrusted: mockMarkTrusted + }, + platform: { + get: () => ({ platform: 'win32' }) } } }) @@ -224,6 +253,41 @@ describe('forkAgentSessionFromPane', () => { ) }) + it('uses Linux startup quoting when a Windows-path project is forced to WSL', async () => { + store.projects = [ + { + id: 'repo-1', + sourceRepoIds: ['repo-1'], + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + } + ] + store.agentStatusByPaneKey = { + [`tab-1:${LEAF_ID}`]: { agentType: 'pi' } + } + mockCreateWorktree.mockResolvedValueOnce({ + worktree: { + id: 'wt-fork', + path: 'C:\\repo\\auth-feature-fork' + } + }) + const { forkAgentSessionFromPane } = await import('./terminal-agent-session-fork') + + await forkAgentSessionFromPane({ + pane: makePane('User: compare OAuth options'), + tabId: 'tab-1', + worktreeId: 'wt-1', + groupId: null + }) + + expect(mockLaunchAgentInNewTab).toHaveBeenCalledWith( + expect.objectContaining({ + agent: 'pi', + worktreeId: 'wt-fork', + launchPlatform: 'linux' + }) + ) + }) + it('still launches the forked agent when trust preflight fails', async () => { store.agentStatusByPaneKey = { [`tab-1:${LEAF_ID}`]: { agentType: 'codex' } diff --git a/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.ts b/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.ts index 65c9ec93926..6fbdcc82349 100644 --- a/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.ts +++ b/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.ts @@ -10,6 +10,8 @@ import { slugifyForWorkspaceName } from '../../../../shared/workspace-name' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import type { TuiAgent } from '../../../../shared/types' import { isWslUncPath } from '../../../../shared/wsl-paths' +import type { ProjectExecutionRuntimeResolution } from '../../../../shared/project-execution-runtime' +import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { translate } from '@/i18n/i18n' type ForkAgentSessionFromPaneArgs = { @@ -86,7 +88,14 @@ async function copyForkContext(prompt: string, pane: ManagedPane): Promise repo.id === sourceWorktree.repoId) + const sourceProjectRuntime = getLocalProjectExecutionRuntimeContext(store, fork.worktreeId) const sourceBranch = getUsableForkBase(sourceWorktree, sourceRepo, fork.worktreeId) if (!sourceBranch) { toast.error( @@ -222,7 +232,8 @@ export async function startAgentSessionFork(fork: PreparedAgentSessionFork): Pro }) const launchPlatform = getForkAgentLaunchPlatform({ repo: sourceRepo, - worktreePath: created.worktree.path + worktreePath: created.worktree.path, + projectRuntime: sourceProjectRuntime }) const result = launchAgentInNewTab({ agent: fork.agent, diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts b/src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts index 18cf42d97a8..c9154306552 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts @@ -7,11 +7,26 @@ const mocks = vi.hoisted(() => ({ importExternalPathsToRuntime: vi.fn(), recordTerminalUserInputForLeaf: vi.fn(), storeState: { + activeRepoId: 'repo1', + activeWorktreeId: 'wt-1', settings: { activeRuntimeEnvironmentId: 'env-1' as string | null }, + projects: [ + { + id: 'repo1', + localWindowsRuntimePreference: { kind: 'inherit-global' as const } + } + ] as { + id: string + localWindowsRuntimePreference: + | { kind: 'inherit-global' } + | { kind: 'windows-host' } + | { kind: 'wsl'; distro: string | null } + }[], repos: [ { id: 'repo1', connectionId: null as string | null, + path: '/remote/repo', executionHostId: 'runtime:env-1' as string | null } ], @@ -40,6 +55,10 @@ vi.mock('@/runtime/runtime-file-client', () => ({ importExternalPathsToRuntime: mocks.importExternalPathsToRuntime })) +vi.mock('@/lib/new-workspace', () => ({ + CLIENT_PLATFORM: 'win32' +})) + vi.mock('./terminal-input-activity', () => ({ recordTerminalUserInputForLeaf: mocks.recordTerminalUserInputForLeaf })) @@ -49,8 +68,18 @@ import { handleTerminalFileDrop, resolveTerminalDropTargetShell } from './termin describe('handleTerminalFileDrop', () => { beforeEach(() => { vi.clearAllMocks() + mocks.storeState.activeRepoId = 'repo1' + mocks.storeState.activeWorktreeId = 'wt-1' mocks.storeState.settings = { activeRuntimeEnvironmentId: 'env-1' } - mocks.storeState.repos = [{ id: 'repo1', connectionId: null, executionHostId: 'runtime:env-1' }] + mocks.storeState.projects = [ + { + id: 'repo1', + localWindowsRuntimePreference: { kind: 'inherit-global' } + } + ] + mocks.storeState.repos = [ + { id: 'repo1', connectionId: null, path: '/remote/repo', executionHostId: 'runtime:env-1' } + ] mocks.storeState.worktreesByRepo = { repo1: [{ id: 'wt-1', repoId: 'repo1', path: '/remote/repo' }] } @@ -148,7 +177,12 @@ describe('handleTerminalFileDrop', () => { it('uploads to the worktree owner runtime instead of the focused runtime', async () => { mocks.storeState.settings = { activeRuntimeEnvironmentId: 'focused-runtime' } mocks.storeState.repos = [ - { id: 'repo1', connectionId: null, executionHostId: 'runtime:owner-runtime' } + { + id: 'repo1', + connectionId: null, + path: '/remote/repo', + executionHostId: 'runtime:owner-runtime' + } ] mocks.importExternalPathsToRuntime.mockResolvedValue({ results: [ @@ -192,7 +226,9 @@ describe('handleTerminalFileDrop', () => { it('keeps explicit local worktree drops local while a runtime is focused', async () => { mocks.storeState.settings = { activeRuntimeEnvironmentId: 'focused-runtime' } - mocks.storeState.repos = [{ id: 'repo1', connectionId: null, executionHostId: 'local' }] + mocks.storeState.repos = [ + { id: 'repo1', connectionId: null, path: '/remote/repo', executionHostId: 'local' } + ] const sendInput = vi.fn(() => true) const focus = vi.fn() const manager = { @@ -214,6 +250,54 @@ describe('handleTerminalFileDrop', () => { expect(sendInput).toHaveBeenCalledWith('/Users/me/spec.pdf ') expect(focus).toHaveBeenCalled() }) + + it('pastes Linux-readable paths for local Windows-path projects forced to WSL', async () => { + mocks.storeState.settings = { activeRuntimeEnvironmentId: 'focused-runtime' } + mocks.storeState.projects = [ + { + id: 'repo1', + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + } + ] + mocks.storeState.repos = [ + { + id: 'repo1', + connectionId: null, + path: 'C:\\Users\\alice\\repo', + executionHostId: 'local' + } + ] + mocks.storeState.worktreesByRepo = { + repo1: [{ id: 'wt-1', repoId: 'repo1', path: 'C:\\Users\\alice\\repo\\feature' }] + } + const sendInput = vi.fn(() => true) + const focus = vi.fn() + const manager = { + getActivePane: () => ({ id: 1, leafId: 'leaf-1', terminal: { focus } }), + getPanes: () => [] + } + const paneTransports = new Map([[1, { sendInput }]]) + + await handleTerminalFileDrop({ + manager: manager as never, + paneTransports: paneTransports as never, + worktreeId: 'wt-1', + tabId: 'tab-1', + cwd: undefined, + data: { + paths: [ + 'C:\\Users\\alice\\Desktop\\notes one.txt', + '\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo\\README.md' + ], + target: 'terminal' + } + }) + + expect(mocks.importExternalPathsToRuntime).not.toHaveBeenCalled() + expect(sendInput).toHaveBeenNthCalledWith(1, "'/mnt/c/Users/alice/Desktop/notes one.txt' ") + expect(sendInput).toHaveBeenNthCalledWith(2, '/home/alice/repo/README.md ') + expect(mocks.recordTerminalUserInputForLeaf).toHaveBeenCalledWith('tab-1', 'leaf-1') + }) }) describe('resolveTerminalDropTargetShell', () => { diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-handler.ts b/src/renderer/src/components/terminal-pane/terminal-drop-handler.ts index e74a9f96000..fa54d4aaa1d 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-handler.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-handler.ts @@ -8,8 +8,11 @@ import { isWindowsUserAgent, shellEscapePath } from './pane-helpers' import type { PtyTransport } from './pty-transport' import { importExternalPathsToRuntime } from '@/runtime/runtime-file-client' import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path' +import { parseWslUncPath } from '../../../../shared/wsl-paths' import { translate } from '@/i18n/i18n' import { recordTerminalUserInputForLeaf } from './terminal-input-activity' +import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' +import { CLIENT_PLATFORM } from '@/lib/new-workspace' type Args = { manager: PaneManager @@ -151,6 +154,8 @@ export async function handleTerminalFileDrop(args: Args): Promise { worktreePath, connectionId }) + const localWslDrop = !isRemote && isWorktreeUsingLocalWslRuntime(state, worktreeId) + const localTargetShell = localWslDrop ? 'posix' : targetShell // Why: local fast path — no IPC round-trip, no toast — preserves today's // zero-latency drop behavior. Trailing space separates multiple paths in @@ -158,7 +163,9 @@ export async function handleTerminalFileDrop(args: Args): Promise { if (!isRemote) { let sentAnyPath = false for (const p of data.paths) { - sentAnyPath = transport.sendInput(`${shellEscapePath(p, targetShell)} `) || sentAnyPath + const terminalPath = localWslDrop ? toLocalWslDropPath(p) : p + sentAnyPath = + transport.sendInput(`${shellEscapePath(terminalPath, localTargetShell)} `) || sentAnyPath } if (sentAnyPath) { recordTerminalUserInputForLeaf(tabId, pane.leafId) @@ -256,3 +263,26 @@ function joinRuntimeDropDir(worktreePath: string): string { function isWindowsPathLike(path: string): boolean { return isWindowsAbsolutePathLike(path) || path.includes('\\') } + +function isWorktreeUsingLocalWslRuntime( + state: ReturnType, + worktreeId: string +): boolean { + const projectRuntime = getLocalProjectExecutionRuntimeContext(state, worktreeId, CLIENT_PLATFORM) + if (projectRuntime?.status === 'repair-required') { + return projectRuntime.repair.preferredRuntime.kind === 'wsl' + } + return projectRuntime?.status === 'resolved' && projectRuntime.runtime.kind === 'wsl' +} + +function toLocalWslDropPath(path: string): string { + const wslUnc = parseWslUncPath(path) + if (wslUnc) { + return wslUnc.linuxPath + } + if (/^[A-Za-z]:[\\/]/.test(path)) { + const drive = path[0].toLowerCase() + return `/mnt/${drive}/${path.slice(3).replace(/\\/g, '/')}` + } + return path.replace(/\\/g, '/') +} diff --git a/src/renderer/src/hooks/useActiveProjectSkillRuntime.ts b/src/renderer/src/hooks/useActiveProjectSkillRuntime.ts new file mode 100644 index 00000000000..bc07518100e --- /dev/null +++ b/src/renderer/src/hooks/useActiveProjectSkillRuntime.ts @@ -0,0 +1,86 @@ +import { useMemo } from 'react' +import { useShallow } from 'zustand/react/shallow' +import type { ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime' +import type { SkillDiscoveryTarget } from '../../../shared/skills' +import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' +import { + getProjectAgentSkillRuntime, + getProjectAgentSkillTerminalShellOverride, + getProjectSkillDiscoveryTarget, + getProjectSkillInstallDisabledReason, + type ProjectAgentSkillRuntime +} from '@/lib/project-skill-runtime' +import { useWindowsTerminalCapabilities } from '@/lib/windows-terminal-capabilities' +import { useAppStore } from '@/store' + +type ActiveProjectSkillRuntime = { + projectRuntime?: ProjectExecutionRuntimeResolution + discoveryTarget?: SkillDiscoveryTarget + agentRuntime?: ProjectAgentSkillRuntime + terminalShellOverride?: string + installDisabledReason: string | null +} + +const EMPTY_ACTIVE_PROJECT_SKILL_RUNTIME: ActiveProjectSkillRuntime = Object.freeze({ + installDisabledReason: null +}) + +export function useActiveProjectSkillRuntime(): ActiveProjectSkillRuntime { + const runtimeState = useAppStore( + useShallow((state) => ({ + activeRepoId: state.activeRepoId, + activeWorktreeId: state.activeWorktreeId, + projects: state.projects, + repos: state.repos, + settings: state.settings, + worktreesByRepo: state.worktreesByRepo + })) + ) + const currentPlatform = getCurrentPlatform() + const windowsCapabilities = useWindowsTerminalCapabilities(currentPlatform === 'win32') + + return useMemo(() => { + const projectRuntime = getLocalProjectExecutionRuntimeContext( + runtimeState, + undefined, + currentPlatform, + { + wslAvailable: windowsCapabilities.isLoading ? undefined : windowsCapabilities.wslAvailable, + availableWslDistros: windowsCapabilities.isLoading ? null : windowsCapabilities.wslDistros + } + ) + if (!projectRuntime) { + return EMPTY_ACTIVE_PROJECT_SKILL_RUNTIME + } + + const agentRuntime = getProjectAgentSkillRuntime(projectRuntime, currentPlatform) + return { + projectRuntime, + discoveryTarget: getProjectSkillDiscoveryTarget(projectRuntime), + agentRuntime, + terminalShellOverride: getProjectAgentSkillTerminalShellOverride( + currentPlatform, + runtimeState.settings, + agentRuntime + ), + installDisabledReason: getProjectSkillInstallDisabledReason(projectRuntime) + } + }, [currentPlatform, runtimeState, windowsCapabilities]) +} + +function getCurrentPlatform(): NodeJS.Platform { + const platform = + typeof window === 'undefined' ? undefined : window.api?.platform?.get?.()?.platform + if (platform) { + return platform + } + + const userAgent = typeof navigator === 'undefined' ? '' : navigator.userAgent + if (userAgent.includes('Windows')) { + return 'win32' + } + if (userAgent.includes('Mac')) { + return 'darwin' + } + return 'linux' +} diff --git a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts index 7b7848d2261..75ecac62f8c 100644 --- a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts +++ b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts @@ -311,4 +311,26 @@ describe('useComposerState host-context boundaries', () => { expect(section).toContain('repoWorktreeBaseRef: selectedRepo.worktreeBaseRef') expect(section).toContain('getRuntimeRepoBaseRefDefault') }) + + it('plans new workspace agent startup from the selected repo runtime', () => { + expect(HOOK_SOURCE).toContain('const selectedRepoAgentLaunchPlatform = useMemo') + expect(HOOK_SOURCE).toContain('getLocalRepoProjectExecutionRuntimeContext') + expect(HOOK_SOURCE).toContain('getAgentLaunchPlatformForRepo(selectedRepo, projectRuntime)') + + const fullSubmit = sourceBetween( + HOOK_SOURCE, + 'const submit = useCallback', + 'const submitQuick = useCallback' + ) + expect(fullSubmit).toContain('platform: selectedRepoAgentLaunchPlatform') + expect(fullSubmit).not.toContain('platform: CLIENT_PLATFORM') + + const quickSubmit = sourceBetween( + HOOK_SOURCE, + 'const submitQuick = useCallback', + 'const createGateInput' + ) + expect(quickSubmit).toContain('platform: selectedRepoAgentLaunchPlatform') + expect(quickSubmit).not.toContain('platform: CLIENT_PLATFORM') + }) }) diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 8a444ffc637..cddf4f312a1 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -7,6 +7,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '@/store' +import { getAgentLaunchPlatformForRepo } from '@/lib/agent-launch-platform' import { getAgentCatalog } from '@/lib/agent-catalog' import { parseGitHubIssueOrPRNumber, @@ -69,6 +70,7 @@ import { getLinkedWorkItemPromptContext, resolveQuickCreateLinkedWorkItemPrompt } from '@/lib/linked-work-item-context' +import { getLocalRepoProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { isOrcaCliAvailableForLaunch } from '@/lib/orca-cli-launch-availability' import { buildLinearIssueLinkedWorkItem, @@ -563,6 +565,26 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS [eligibleRepos, projectHostSetups, projects, repoId, workspaceHostScope] ) const selectedRepo = eligibleRepos.find((repo) => repo.id === repoId) + const selectedRepoAgentLaunchPlatform = useMemo(() => { + if (!selectedRepo) { + return CLIENT_PLATFORM + } + const projectRuntime = selectedRepo.connectionId + ? undefined + : getLocalRepoProjectExecutionRuntimeContext( + { + activeRepoId, + activeWorktreeId: null, + projects, + repos, + settings, + worktreesByRepo + }, + selectedRepo.id, + CLIENT_PLATFORM + ) + return getAgentLaunchPlatformForRepo(selectedRepo, projectRuntime) + }, [activeRepoId, projects, repos, selectedRepo, settings, worktreesByRepo]) const selectedRepoProjectId = selectedWorkspaceTarget.status === 'ready' ? selectedWorkspaceTarget.target.projectId : null const selectedProjectId = selectedProjectGroup @@ -2854,7 +2876,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS cmdOverrides: settings?.agentCmdOverrides ?? {}, agentArgs: resolveTuiAgentLaunchArgs(tuiAgent, settings?.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(tuiAgent, settings?.agentDefaultEnv), - platform: CLIENT_PLATFORM + platform: selectedRepoAgentLaunchPlatform }) // Why: backend startup is safe only when the launch command is @@ -2996,6 +3018,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS resolvedSetupDecision, resolvedInitialWorkspaceStatus, selectedRepo, + selectedRepoAgentLaunchPlatform, selectedRepoIsGit, selectedRepoRequiresConnection, showProjectRequiredError, @@ -3162,7 +3185,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS cmdOverrides: settings?.agentCmdOverrides ?? {}, agentArgs: resolveTuiAgentLaunchArgs(agent, settings?.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(agent, settings?.agentDefaultEnv), - platform: CLIENT_PLATFORM + platform: selectedRepoAgentLaunchPlatform }) let startupPlan: ReturnType = null @@ -3181,7 +3204,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS cmdOverrides: settings?.agentCmdOverrides ?? {}, agentArgs: resolveTuiAgentLaunchArgs(agent, settings?.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(agent, settings?.agentDefaultEnv), - platform: CLIENT_PLATFORM, + platform: selectedRepoAgentLaunchPlatform, allowEmptyPromptLaunch: true }) if (startupPlan && quickDraftPrompt) { @@ -3302,6 +3325,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS resolvedSetupDecision, resolvedInitialWorkspaceStatus, selectedRepo, + selectedRepoAgentLaunchPlatform, selectedRepoIsGit, selectedRepoSettings, selectedRepoRequiresConnection, diff --git a/src/renderer/src/hooks/useInstalledAgentSkills.test.ts b/src/renderer/src/hooks/useInstalledAgentSkills.test.ts index 14e51aa057f..0d2c5148b10 100644 --- a/src/renderer/src/hooks/useInstalledAgentSkills.test.ts +++ b/src/renderer/src/hooks/useInstalledAgentSkills.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { DiscoveredSkill, SkillDiscoveryResult } from '../../../shared/skills' +import type { ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime' import { GLOBAL_AGENT_SKILL_SOURCE_KINDS, _installedAgentSkillDiscoveryInternalsForTests, @@ -129,6 +130,29 @@ describe('isOrchestrationSkillName', () => { }) describe('discoverInstalledAgentSkills', () => { + const projectWslRuntime: ProjectExecutionRuntimeResolution = { + status: 'resolved', + runtime: { + kind: 'wsl', + hostPlatform: 'wsl', + projectId: 'repo-1', + distro: 'Ubuntu', + reason: 'project-override', + cacheKey: 'repo-1:wsl:Ubuntu' + } + } + + const projectHostRuntime: ProjectExecutionRuntimeResolution = { + status: 'resolved', + runtime: { + kind: 'windows-host', + hostPlatform: 'win32', + projectId: 'repo-1', + reason: 'project-override', + cacheKey: 'repo-1:windows-host' + } + } + it('starts a fresh scan when a forced refresh arrives during a background scan', async () => { const firstScan = deferred() const secondScan = deferred() @@ -189,4 +213,57 @@ describe('discoverInstalledAgentSkills', () => { expect(discover).toHaveBeenNthCalledWith(1, undefined) expect(discover).toHaveBeenNthCalledWith(2, { runtime: 'wsl', wslDistro: null }) }) + + it('forwards project runtime targets to skill discovery', async () => { + const wslResult = discoveryResult([skill({ name: 'wsl-skill' })]) + const discover = vi.fn().mockResolvedValueOnce(wslResult) + vi.stubGlobal('window', { + api: { skills: { discover } } + }) + + await expect( + _installedAgentSkillDiscoveryInternalsForTests.discoverInstalledAgentSkills(false, { + projectRuntime: projectWslRuntime + }) + ).resolves.toBe(wslResult) + + expect(discover).toHaveBeenCalledWith({ + runtime: 'wsl', + wslDistro: 'Ubuntu', + projectRuntime: projectWslRuntime + }) + }) + + it('caches project host runtime separately from generic host discovery', async () => { + const genericHostResult = discoveryResult([skill({ name: 'generic-host-skill' })]) + const projectHostResult = discoveryResult([skill({ name: 'project-host-skill' })]) + const discover = vi + .fn() + .mockResolvedValueOnce(genericHostResult) + .mockResolvedValueOnce(projectHostResult) + vi.stubGlobal('window', { + api: { skills: { discover } } + }) + + await expect( + _installedAgentSkillDiscoveryInternalsForTests.discoverInstalledAgentSkills(false) + ).resolves.toBe(genericHostResult) + await expect( + _installedAgentSkillDiscoveryInternalsForTests.discoverInstalledAgentSkills(false, { + projectRuntime: projectHostRuntime + }) + ).resolves.toBe(projectHostResult) + await expect( + _installedAgentSkillDiscoveryInternalsForTests.discoverInstalledAgentSkills(false, { + projectRuntime: projectHostRuntime + }) + ).resolves.toBe(projectHostResult) + + expect(discover).toHaveBeenCalledTimes(2) + expect(discover).toHaveBeenNthCalledWith(1, undefined) + expect(discover).toHaveBeenNthCalledWith(2, { + runtime: 'host', + projectRuntime: projectHostRuntime + }) + }) }) diff --git a/src/renderer/src/hooks/useInstalledAgentSkills.ts b/src/renderer/src/hooks/useInstalledAgentSkills.ts index bf6f186610a..05b04ad4285 100644 --- a/src/renderer/src/hooks/useInstalledAgentSkills.ts +++ b/src/renderer/src/hooks/useInstalledAgentSkills.ts @@ -78,6 +78,24 @@ export function notifyInstalledAgentSkillsChanged(): void { function normalizeSkillDiscoveryTarget( target: SkillDiscoveryTarget | undefined ): SkillDiscoveryTarget | undefined { + const projectRuntime = target?.projectRuntime + if (projectRuntime) { + if (projectRuntime.status === 'repair-required') { + return { projectRuntime } + } + if (projectRuntime.runtime.kind === 'wsl') { + return { + runtime: 'wsl', + wslDistro: projectRuntime.runtime.distro, + projectRuntime + } + } + return { + runtime: 'host', + projectRuntime + } + } + if (target?.runtime !== 'wsl') { return undefined } @@ -85,6 +103,11 @@ function normalizeSkillDiscoveryTarget( } function getSkillDiscoveryTargetKey(target: SkillDiscoveryTarget | undefined): string { + if (target?.projectRuntime) { + return target.projectRuntime.status === 'resolved' + ? target.projectRuntime.runtime.cacheKey + : target.projectRuntime.repair.cacheKey + } const normalizedTarget = normalizeSkillDiscoveryTarget(target) return normalizedTarget?.runtime === 'wsl' ? `wsl:${normalizedTarget.wslDistro ?? ''}` : 'host' } diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts index 1d1891d2b6e..595e11cc045 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts @@ -74,6 +74,61 @@ describe('settings navigation metadata', () => { expect(sections.find((section) => section.id === 'voice')?.badge).toBeUndefined() }) + it('omits Windows project runtime search entries when the active host is unsupported', () => { + const sections = buildSettingsNavigationMetadata({ + isMac: false, + isWindows: false, + isWindowsTerminalHost: false, + isWebClient: false, + repos: [repo] + }) + + const general = sections.find((section) => section.id === 'general') + const repoSection = sections.find((section) => section.id === 'repo-repo-1') + + expect(general?.searchEntries.some((entry) => entry.title === 'Default Project Runtime')).toBe( + false + ) + expect(repoSection?.searchEntries.some((entry) => entry.title === 'Project Runtime')).toBe( + false + ) + }) + + it('includes project runtime search entries for local repos on Windows hosts', () => { + const sections = buildSettingsNavigationMetadata({ + isMac: false, + isWindows: true, + isWebClient: false, + repos: [repo] + }) + + const general = sections.find((section) => section.id === 'general') + const repoSection = sections.find((section) => section.id === 'repo-repo-1') + + expect(general?.searchEntries.some((entry) => entry.title === 'Default Project Runtime')).toBe( + true + ) + expect(repoSection?.searchEntries.some((entry) => entry.title === 'Project Runtime')).toBe(true) + }) + + it('keeps Windows client-only terminal settings out of Windows-host metadata', () => { + const sections = buildSettingsNavigationMetadata({ + isMac: false, + isWindows: false, + isWindowsTerminalHost: true, + isWebClient: false, + repos: [repo] + }) + + const terminal = sections.find((section) => section.id === 'terminal') + + expect(terminal?.searchEntries.some((entry) => entry.title === 'Default Shell')).toBe(true) + expect(terminal?.searchEntries.some((entry) => entry.title === 'PowerShell Version')).toBe(true) + expect(terminal?.searchEntries.some((entry) => entry.title === 'Right-click to paste')).toBe( + false + ) + }) + it('places Advanced near the bottom on desktop without putting it under Experimental', () => { const desktopIds = ids() diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts index 04acdbedc3c..6a06b749829 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts @@ -98,7 +98,8 @@ export function buildSettingsNavigationMetadata({ }): SettingsNavSection[] { const showDesktopOnlySettings = !isWebClient const terminalPaneSearchEntries = getTerminalPaneSearchEntries({ - isWindows: isWindowsTerminalHost, + isWindows, + isWindowsTerminalHost, isMac }) const runtimeEnvironmentsSearchEntry = isWebClient @@ -213,7 +214,7 @@ export function buildSettingsNavigationMetadata({ 'Workspace defaults, app setup, and maintenance.' ), icon: SlidersHorizontal, - searchEntries: getGeneralPaneSearchEntries(), + searchEntries: getGeneralPaneSearchEntries({ includeProjectRuntime: isWindowsTerminalHost }), group: 'setup' }, { @@ -489,7 +490,9 @@ export function buildSettingsNavigationMetadata({ title: repo.displayName, description: `${getRepoKindLabel(repo)} • ${repo.path}`, icon: SlidersHorizontal, - searchEntries: getRepositoryPaneSearchEntries(repo), + searchEntries: getRepositoryPaneSearchEntries(repo, { + windowsRuntimeSupported: isWindowsTerminalHost + }), group: 'repositories' })) ] diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index e7deee5de1d..6b4591917a9 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -495,6 +495,12 @@ "unavailable": "Orca cannot verify this folder right now. Check the runtime or SSH connection and try again." } } + }, + "projectSkillRuntime": { + "wslUnavailable": "Project runtime needs WSL before this skill can be installed.", + "distroRequired": "Select a WSL distro for this project before installing this skill.", + "distroMissing": "The selected WSL distro is unavailable. Choose an available distro or switch this project to Windows.", + "wslDefault": "WSL default" } }, "hooks": { @@ -4762,7 +4768,9 @@ "c46cdbbd4e": "Network" }, "GeneralPane": { - "d58fccfd84": "Navigation" + "d58fccfd84": "Navigation", + "projectRuntime": "Project Runtime", + "projectRuntimeDescription": "Default runtime for local Windows projects that do not override it." }, "GeneralSupportSection": { "af7d9f4396": "Thanks for the support!", @@ -5491,7 +5499,9 @@ "addPlannedHostHelp": "Remember this host and finish adding the project later.", "existingFolder": "Existing folder", "addPlannedHostToHost": "Add {{host}}", - "addPlannedHostConfirm": "This only records that the project should be available on this host. You can add the folder or clone later." + "addPlannedHostConfirm": "This only records that the project should be available on this host. You can add the folder or clone later.", + "projectRuntime": "Project Runtime", + "projectRuntimeDescription": "Choose whether this project runs on Windows or WSL." }, "RepositorySourceControlAiActionRows": { "548a6e1281": "Command template", @@ -6964,7 +6974,9 @@ "7887a2c262": "folder", "7baf524b04": "workspace", "d0bc793689": "Root directory where workspace folders are created.", - "4c95d08fa2": "Workspace Directory" + "4c95d08fa2": "Workspace Directory", + "defaultProjectRuntime": "Default Project Runtime", + "defaultProjectRuntimeDescription": "Choose the runtime inherited by local Windows projects." } }, "git": { @@ -7429,7 +7441,9 @@ "883aad2801": "Project-specific display details for the sidebar and tabs.", "7e1e456a95": "Display Name", "keepForkUpToDate": "Keep Fork Up to Date", - "keepForkUpToDateDescription": "Safely fast-forward this fork from upstream." + "keepForkUpToDateDescription": "Safely fast-forward this fork from upstream.", + "projectRuntime": "Project Runtime", + "projectRuntimeDescription": "Choose whether this project runs on Windows or WSL." } }, "runtime": { @@ -7989,6 +8003,40 @@ "safeAuto": "Safe Auto", "off": "Off" }, + "DefaultWindowsProjectRuntimeSetting": { + "defaultRuntime": "Default project runtime", + "windows": "Windows", + "wsl": "WSL", + "selectDistro": "Select distro", + "windowsDescription": "Projects inherit Windows unless a project overrides it.", + "wslUnavailable": "WSL is not available. Projects that inherit WSL will need repair.", + "distroRequired": "Choose a WSL distro before projects can inherit WSL.", + "wslDescription": "Projects inherit {{value0}} via WSL unless a project overrides it." + }, + "ProjectWindowsRuntimeSetting": { + "projectRuntime": "Project runtime", + "defaultRuntime": "Default ({{value0}})", + "windows": "Windows", + "wsl": "WSL", + "selectDistro": "Select distro", + "runtimeChangeHelp": "Runtime changes apply to new terminals, agent checks, and skill discovery for this project. Existing terminals keep their current runtime.", + "wslUnavailable": "WSL is not available. Switch this project to Windows or repair WSL.", + "distroMissing": "{{value0}} is not installed in WSL. Choose an installed distro or switch this project to Windows.", + "distroRequired": "Choose a WSL distro or switch this project to Windows.", + "inheritedWsl": "No project override. General settings select {{value0}} via WSL.", + "projectWsl": "This project runs in {{value0}} via WSL.", + "inheritedWindows": "No project override. General settings select Windows.", + "projectWindows": "This project runs on Windows.", + "liveTerminalSingular": "{{count}} live terminal", + "liveTerminalPlural": "{{count}} live terminals", + "activeTaskSingular": "{{count}} active task", + "activeTaskPlural": "{{count}} active tasks", + "runtimeSessionJoin": "{{value0}} and {{value1}}", + "runtimeSessionWarning": "{{value0}} will keep running in the current runtime. Let tasks finish or restart terminals before continuing.", + "pendingRuntimeChange": "Runtime change pending. New project work will use the selected runtime after you apply.", + "cancel": "Cancel", + "applyRuntimeChange": "Apply runtime change" + }, "PrivacyDiagnosticsRows": { "5a7cbe069a": "DO_NOT_TRACK=1 is set — creating and sending diagnostic files is disabled.", "63d03261d1": "ORCA_TELEMETRY_DISABLED=1 is set — creating and sending diagnostic files is disabled.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 9956c92c1d7..0555cdda068 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -495,6 +495,12 @@ "unavailable": "Orca no puede verificar esta carpeta ahora mismo. Revisa el runtime o la conexión SSH e inténtalo de nuevo." } } + }, + "projectSkillRuntime": { + "wslUnavailable": "Project runtime needs WSL before this skill can be installed.", + "distroRequired": "Select a WSL distro for this project before installing this skill.", + "distroMissing": "The selected WSL distro is unavailable. Choose an available distro or switch this project to Windows.", + "wslDefault": "WSL default" } }, "hooks": { @@ -4762,7 +4768,9 @@ "c46cdbbd4e": "Red" }, "GeneralPane": { - "d58fccfd84": "Navegación" + "d58fccfd84": "Navegación", + "projectRuntime": "Project Runtime", + "projectRuntimeDescription": "Default runtime for local Windows projects that do not override it." }, "GeneralSupportSection": { "af7d9f4396": "¡Gracias por el apoyo!", @@ -5454,7 +5462,9 @@ "addPlannedHostHelp": "Remember this host and finish adding the project later.", "existingFolder": "Existing folder", "addPlannedHostToHost": "Add {{host}}", - "addPlannedHostConfirm": "This only records that the project should be available on this host. You can add the folder or clone later." + "addPlannedHostConfirm": "This only records that the project should be available on this host. You can add the folder or clone later.", + "projectRuntime": "Project Runtime", + "projectRuntimeDescription": "Choose whether this project runs on Windows or WSL." }, "RepositorySourceControlAiActionRows": { "548a6e1281": "Plantilla de comando", @@ -6927,7 +6937,9 @@ "7887a2c262": "carpeta", "7baf524b04": "espacio de trabajo", "d0bc793689": "Directorio raíz donde se crean las carpetas del espacio de trabajo.", - "4c95d08fa2": "Directorio de espacio de trabajo" + "4c95d08fa2": "Directorio de espacio de trabajo", + "defaultProjectRuntime": "Default Project Runtime", + "defaultProjectRuntimeDescription": "Choose the runtime inherited by local Windows projects." } }, "git": { @@ -7392,7 +7404,9 @@ "remote": "remote", "vm": "vm", "keepForkUpToDate": "Mantener el fork actualizado", - "keepForkUpToDateDescription": "Avanza este fork de forma segura desde upstream con fast-forward." + "keepForkUpToDateDescription": "Avanza este fork de forma segura desde upstream con fast-forward.", + "projectRuntime": "Project Runtime", + "projectRuntimeDescription": "Choose whether this project runs on Windows or WSL." } }, "runtime": { @@ -7989,6 +8003,40 @@ "safeAuto": "Seguro automático", "off": "Desactivado" }, + "DefaultWindowsProjectRuntimeSetting": { + "defaultRuntime": "Default project runtime", + "windows": "Windows", + "wsl": "WSL", + "selectDistro": "Select distro", + "windowsDescription": "Projects inherit Windows unless a project overrides it.", + "wslUnavailable": "WSL is not available. Projects that inherit WSL will need repair.", + "distroRequired": "Choose a WSL distro before projects can inherit WSL.", + "wslDescription": "Projects inherit {{value0}} via WSL unless a project overrides it." + }, + "ProjectWindowsRuntimeSetting": { + "projectRuntime": "Project runtime", + "defaultRuntime": "Default ({{value0}})", + "windows": "Windows", + "wsl": "WSL", + "selectDistro": "Select distro", + "runtimeChangeHelp": "Runtime changes apply to new terminals, agent checks, and skill discovery for this project. Existing terminals keep their current runtime.", + "wslUnavailable": "WSL is not available. Switch this project to Windows or repair WSL.", + "distroMissing": "{{value0}} is not installed in WSL. Choose an installed distro or switch this project to Windows.", + "distroRequired": "Choose a WSL distro or switch this project to Windows.", + "inheritedWsl": "No project override. General settings select {{value0}} via WSL.", + "projectWsl": "This project runs in {{value0}} via WSL.", + "inheritedWindows": "No project override. General settings select Windows.", + "projectWindows": "This project runs on Windows.", + "liveTerminalSingular": "{{count}} terminal activo", + "liveTerminalPlural": "{{count}} terminales activos", + "activeTaskSingular": "{{count}} tarea activa", + "activeTaskPlural": "{{count}} tareas activas", + "runtimeSessionJoin": "{{value0}} y {{value1}}", + "runtimeSessionWarning": "{{value0}} seguirá ejecutándose en el runtime actual. Deja que las tareas terminen o reinicia las terminales antes de continuar.", + "pendingRuntimeChange": "Runtime change pending. New project work will use the selected runtime after you apply.", + "cancel": "Cancel", + "applyRuntimeChange": "Apply runtime change" + }, "PrivacyDiagnosticsRows": { "5a7cbe069a": "DO_NOT_TRACK=1 está configurado: crear y enviar archivos de diagnóstico está deshabilitado.", "63d03261d1": "ORCA_TELEMETRY_DISABLED=1 está configurado: crear y enviar archivos de diagnóstico está deshabilitado.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 9aba0a89da4..5e4dab6e9da 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -495,6 +495,12 @@ "unavailable": "Orca は現在このフォルダーを確認できません。ランタイムまたは SSH 接続を確認して再試行してください。" } } + }, + "projectSkillRuntime": { + "wslUnavailable": "Project runtime needs WSL before this skill can be installed.", + "distroRequired": "Select a WSL distro for this project before installing this skill.", + "distroMissing": "The selected WSL distro is unavailable. Choose an available distro or switch this project to Windows.", + "wslDefault": "WSL default" } }, "hooks": { @@ -4747,7 +4753,9 @@ "c46cdbbd4e": "ネットワーク" }, "GeneralPane": { - "d58fccfd84": "ナビゲーション" + "d58fccfd84": "ナビゲーション", + "projectRuntime": "Project Runtime", + "projectRuntimeDescription": "Default runtime for local Windows projects that do not override it." }, "GeneralSupportSection": { "af7d9f4396": "サポートありがとうございます!", @@ -5476,7 +5484,9 @@ "addPlannedHostHelp": "Remember this host and finish adding the project later.", "existingFolder": "Existing folder", "addPlannedHostToHost": "Add {{host}}", - "addPlannedHostConfirm": "This only records that the project should be available on this host. You can add the folder or clone later." + "addPlannedHostConfirm": "This only records that the project should be available on this host. You can add the folder or clone later.", + "projectRuntime": "Project Runtime", + "projectRuntimeDescription": "Choose whether this project runs on Windows or WSL." }, "RepositorySourceControlAiActionRows": { "548a6e1281": "コマンドテンプレート", @@ -6949,7 +6959,9 @@ "7887a2c262": "フォルダ", "7baf524b04": "ワークスペース", "d0bc793689": "ワークスペースフォルダーが作成されるルートディレクトリ。", - "4c95d08fa2": "ワークスペースディレクトリ" + "4c95d08fa2": "ワークスペースディレクトリ", + "defaultProjectRuntime": "Default Project Runtime", + "defaultProjectRuntimeDescription": "Choose the runtime inherited by local Windows projects." } }, "git": { @@ -7414,7 +7426,9 @@ "remote": "remote", "vm": "vm", "keepForkUpToDate": "フォークを最新に保つ", - "keepForkUpToDateDescription": "このフォークを upstream から安全に fast-forward します。" + "keepForkUpToDateDescription": "このフォークを upstream から安全に fast-forward します。", + "projectRuntime": "Project Runtime", + "projectRuntimeDescription": "Choose whether this project runs on Windows or WSL." } }, "runtime": { @@ -7989,6 +8003,40 @@ "safeAuto": "安全に自動", "off": "オフ" }, + "DefaultWindowsProjectRuntimeSetting": { + "defaultRuntime": "Default project runtime", + "windows": "Windows", + "wsl": "WSL", + "selectDistro": "Select distro", + "windowsDescription": "Projects inherit Windows unless a project overrides it.", + "wslUnavailable": "WSL is not available. Projects that inherit WSL will need repair.", + "distroRequired": "Choose a WSL distro before projects can inherit WSL.", + "wslDescription": "Projects inherit {{value0}} via WSL unless a project overrides it." + }, + "ProjectWindowsRuntimeSetting": { + "projectRuntime": "Project runtime", + "defaultRuntime": "Default ({{value0}})", + "windows": "Windows", + "wsl": "WSL", + "selectDistro": "Select distro", + "runtimeChangeHelp": "Runtime changes apply to new terminals, agent checks, and skill discovery for this project. Existing terminals keep their current runtime.", + "wslUnavailable": "WSL is not available. Switch this project to Windows or repair WSL.", + "distroMissing": "{{value0}} is not installed in WSL. Choose an installed distro or switch this project to Windows.", + "distroRequired": "Choose a WSL distro or switch this project to Windows.", + "inheritedWsl": "No project override. General settings select {{value0}} via WSL.", + "projectWsl": "This project runs in {{value0}} via WSL.", + "inheritedWindows": "No project override. General settings select Windows.", + "projectWindows": "This project runs on Windows.", + "liveTerminalSingular": "{{count}} 個の実行中ターミナル", + "liveTerminalPlural": "{{count}} 個の実行中ターミナル", + "activeTaskSingular": "{{count}} 件のアクティブなタスク", + "activeTaskPlural": "{{count}} 件のアクティブなタスク", + "runtimeSessionJoin": "{{value0}}と{{value1}}", + "runtimeSessionWarning": "{{value0}}は現在のランタイムで実行を続けます。続行する前にタスクの完了を待つか、ターミナルを再起動してください。", + "pendingRuntimeChange": "Runtime change pending. New project work will use the selected runtime after you apply.", + "cancel": "Cancel", + "applyRuntimeChange": "Apply runtime change" + }, "PrivacyDiagnosticsRows": { "5a7cbe069a": "DO_NOT_TRACK=1 が設定されています — 診断ファイルの作成と送信は無効です。", "63d03261d1": "ORCA_TELEMETRY_DISABLED=1 が設定されています — 診断ファイルの作成と送信は無効です。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 988e68956e1..1e768ebba05 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -495,6 +495,12 @@ "unavailable": "Orca가 지금 이 폴더를 확인할 수 없습니다. 런타임 또는 SSH 연결을 확인한 후 다시 시도하세요." } } + }, + "projectSkillRuntime": { + "wslUnavailable": "Project runtime needs WSL before this skill can be installed.", + "distroRequired": "Select a WSL distro for this project before installing this skill.", + "distroMissing": "The selected WSL distro is unavailable. Choose an available distro or switch this project to Windows.", + "wslDefault": "WSL default" } }, "hooks": { @@ -4747,7 +4753,9 @@ "c46cdbbd4e": "회로망" }, "GeneralPane": { - "d58fccfd84": "항해" + "d58fccfd84": "항해", + "projectRuntime": "Project Runtime", + "projectRuntimeDescription": "Default runtime for local Windows projects that do not override it." }, "GeneralSupportSection": { "af7d9f4396": "지원해 주셔서 감사합니다!", @@ -5439,7 +5447,9 @@ "addPlannedHostHelp": "Remember this host and finish adding the project later.", "existingFolder": "Existing folder", "addPlannedHostToHost": "Add {{host}}", - "addPlannedHostConfirm": "This only records that the project should be available on this host. You can add the folder or clone later." + "addPlannedHostConfirm": "This only records that the project should be available on this host. You can add the folder or clone later.", + "projectRuntime": "Project Runtime", + "projectRuntimeDescription": "Choose whether this project runs on Windows or WSL." }, "RepositorySourceControlAiActionRows": { "548a6e1281": "명령 템플릿", @@ -6912,7 +6922,9 @@ "7887a2c262": "폴더", "7baf524b04": "워크스페이스", "d0bc793689": "워크스페이스 폴더가 생성되는 루트 디렉터리입니다.", - "4c95d08fa2": "워크스페이스 디렉토리" + "4c95d08fa2": "워크스페이스 디렉토리", + "defaultProjectRuntime": "Default Project Runtime", + "defaultProjectRuntimeDescription": "Choose the runtime inherited by local Windows projects." } }, "git": { @@ -7377,7 +7389,9 @@ "remote": "remote", "vm": "vm", "keepForkUpToDate": "포크를 최신 상태로 유지", - "keepForkUpToDateDescription": "이 포크를 upstream에서 안전하게 fast-forward합니다." + "keepForkUpToDateDescription": "이 포크를 upstream에서 안전하게 fast-forward합니다.", + "projectRuntime": "Project Runtime", + "projectRuntimeDescription": "Choose whether this project runs on Windows or WSL." } }, "runtime": { @@ -7989,6 +8003,40 @@ "safeAuto": "안전 자동", "off": "끄기" }, + "DefaultWindowsProjectRuntimeSetting": { + "defaultRuntime": "Default project runtime", + "windows": "Windows", + "wsl": "WSL", + "selectDistro": "Select distro", + "windowsDescription": "Projects inherit Windows unless a project overrides it.", + "wslUnavailable": "WSL is not available. Projects that inherit WSL will need repair.", + "distroRequired": "Choose a WSL distro before projects can inherit WSL.", + "wslDescription": "Projects inherit {{value0}} via WSL unless a project overrides it." + }, + "ProjectWindowsRuntimeSetting": { + "projectRuntime": "Project runtime", + "defaultRuntime": "Default ({{value0}})", + "windows": "Windows", + "wsl": "WSL", + "selectDistro": "Select distro", + "runtimeChangeHelp": "Runtime changes apply to new terminals, agent checks, and skill discovery for this project. Existing terminals keep their current runtime.", + "wslUnavailable": "WSL is not available. Switch this project to Windows or repair WSL.", + "distroMissing": "{{value0}} is not installed in WSL. Choose an installed distro or switch this project to Windows.", + "distroRequired": "Choose a WSL distro or switch this project to Windows.", + "inheritedWsl": "No project override. General settings select {{value0}} via WSL.", + "projectWsl": "This project runs in {{value0}} via WSL.", + "inheritedWindows": "No project override. General settings select Windows.", + "projectWindows": "This project runs on Windows.", + "liveTerminalSingular": "{{count}}개의 실행 중인 터미널", + "liveTerminalPlural": "{{count}}개의 실행 중인 터미널", + "activeTaskSingular": "{{count}}개의 활성 작업", + "activeTaskPlural": "{{count}}개의 활성 작업", + "runtimeSessionJoin": "{{value0}} 및 {{value1}}", + "runtimeSessionWarning": "{{value0}}은 현재 런타임에서 계속 실행됩니다. 계속하기 전에 작업이 끝나게 하거나 터미널을 다시 시작하세요.", + "pendingRuntimeChange": "Runtime change pending. New project work will use the selected runtime after you apply.", + "cancel": "Cancel", + "applyRuntimeChange": "Apply runtime change" + }, "PrivacyDiagnosticsRows": { "5a7cbe069a": "DO_NOT_TRACK=1이 설정되어 있어 진단 파일 만들기와 보내기가 비활성화되었습니다.", "63d03261d1": "ORCA_TELEMETRY_DISABLED=1이 설정되어 있어 진단 파일 만들기와 보내기가 비활성화되었습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index c628e90590c..c9551866657 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -495,6 +495,12 @@ "unavailable": "Orca 现在无法验证此文件夹。请检查运行时或 SSH 连接,然后重试。" } } + }, + "projectSkillRuntime": { + "wslUnavailable": "Project runtime needs WSL before this skill can be installed.", + "distroRequired": "Select a WSL distro for this project before installing this skill.", + "distroMissing": "The selected WSL distro is unavailable. Choose an available distro or switch this project to Windows.", + "wslDefault": "WSL default" } }, "hooks": { @@ -4747,7 +4753,9 @@ "c46cdbbd4e": "网络" }, "GeneralPane": { - "d58fccfd84": "导航" + "d58fccfd84": "导航", + "projectRuntime": "Project Runtime", + "projectRuntimeDescription": "Default runtime for local Windows projects that do not override it." }, "GeneralSupportSection": { "af7d9f4396": "感谢您的支持!", @@ -5439,7 +5447,9 @@ "addPlannedHostHelp": "Remember this host and finish adding the project later.", "existingFolder": "Existing folder", "addPlannedHostToHost": "Add {{host}}", - "addPlannedHostConfirm": "This only records that the project should be available on this host. You can add the folder or clone later." + "addPlannedHostConfirm": "This only records that the project should be available on this host. You can add the folder or clone later.", + "projectRuntime": "Project Runtime", + "projectRuntimeDescription": "Choose whether this project runs on Windows or WSL." }, "RepositorySourceControlAiActionRows": { "548a6e1281": "命令模板", @@ -6912,7 +6922,9 @@ "7887a2c262": "文件夹", "7baf524b04": "工作区", "d0bc793689": "创建工作区文件夹的根目录。", - "4c95d08fa2": "工作区目录" + "4c95d08fa2": "工作区目录", + "defaultProjectRuntime": "Default Project Runtime", + "defaultProjectRuntimeDescription": "Choose the runtime inherited by local Windows projects." } }, "git": { @@ -7377,7 +7389,9 @@ "remote": "远程", "vm": "vm", "keepForkUpToDate": "保持 Fork 最新", - "keepForkUpToDateDescription": "从 upstream 安全地快进此 Fork。" + "keepForkUpToDateDescription": "从 upstream 安全地快进此 Fork。", + "projectRuntime": "Project Runtime", + "projectRuntimeDescription": "Choose whether this project runs on Windows or WSL." } }, "runtime": { @@ -7989,6 +8003,40 @@ "safeAuto": "安全自动", "off": "关闭" }, + "DefaultWindowsProjectRuntimeSetting": { + "defaultRuntime": "Default project runtime", + "windows": "Windows", + "wsl": "WSL", + "selectDistro": "Select distro", + "windowsDescription": "Projects inherit Windows unless a project overrides it.", + "wslUnavailable": "WSL is not available. Projects that inherit WSL will need repair.", + "distroRequired": "Choose a WSL distro before projects can inherit WSL.", + "wslDescription": "Projects inherit {{value0}} via WSL unless a project overrides it." + }, + "ProjectWindowsRuntimeSetting": { + "projectRuntime": "Project runtime", + "defaultRuntime": "Default ({{value0}})", + "windows": "Windows", + "wsl": "WSL", + "selectDistro": "Select distro", + "runtimeChangeHelp": "Runtime changes apply to new terminals, agent checks, and skill discovery for this project. Existing terminals keep their current runtime.", + "wslUnavailable": "WSL is not available. Switch this project to Windows or repair WSL.", + "distroMissing": "{{value0}} is not installed in WSL. Choose an installed distro or switch this project to Windows.", + "distroRequired": "Choose a WSL distro or switch this project to Windows.", + "inheritedWsl": "No project override. General settings select {{value0}} via WSL.", + "projectWsl": "This project runs in {{value0}} via WSL.", + "inheritedWindows": "No project override. General settings select Windows.", + "projectWindows": "This project runs on Windows.", + "liveTerminalSingular": "{{count}} 个正在运行的终端", + "liveTerminalPlural": "{{count}} 个正在运行的终端", + "activeTaskSingular": "{{count}} 个活动任务", + "activeTaskPlural": "{{count}} 个活动任务", + "runtimeSessionJoin": "{{value0}}和{{value1}}", + "runtimeSessionWarning": "{{value0}}会继续在当前运行时中运行。继续前请等待任务完成或重启终端。", + "pendingRuntimeChange": "Runtime change pending. New project work will use the selected runtime after you apply.", + "cancel": "Cancel", + "applyRuntimeChange": "Apply runtime change" + }, "PrivacyDiagnosticsRows": { "5a7cbe069a": "已设置 DO_NOT_TRACK=1,创建和发送诊断文件已停用。", "63d03261d1": "已设置 ORCA_TELEMETRY_DISABLED=1,创建和发送诊断文件已停用。", diff --git a/src/renderer/src/lib/agent-launch-platform.ts b/src/renderer/src/lib/agent-launch-platform.ts index a16f2c0fabf..0788610892b 100644 --- a/src/renderer/src/lib/agent-launch-platform.ts +++ b/src/renderer/src/lib/agent-launch-platform.ts @@ -1,11 +1,19 @@ import { isWindowsAbsolutePathLike } from '../../../shared/cross-platform-path' import { CLIENT_PLATFORM } from '@/lib/new-workspace' import type { AppState } from '@/store' +import type { ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime' export function getAgentLaunchPlatformForRepo( - repo: Pick + repo: Pick, + projectRuntime?: ProjectExecutionRuntimeResolution ): NodeJS.Platform { if (!repo.connectionId) { + if (projectRuntime?.status === 'repair-required') { + return projectRuntime.repair.preferredRuntime.kind === 'wsl' ? 'linux' : CLIENT_PLATFORM + } + if (projectRuntime?.status === 'resolved' && projectRuntime.runtime.kind === 'wsl') { + return 'linux' + } return CLIENT_PLATFORM } return isWindowsAbsolutePathLike(repo.path) ? 'win32' : 'linux' diff --git a/src/renderer/src/lib/ai-vault-resume-command.test.ts b/src/renderer/src/lib/ai-vault-resume-command.test.ts new file mode 100644 index 00000000000..c52173b45fd --- /dev/null +++ b/src/renderer/src/lib/ai-vault-resume-command.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AppState } from '@/store/types' +import { + buildAiVaultResumeCommandForWorktree, + getAiVaultResumePlatform +} from './ai-vault-resume-command' + +vi.mock('@/lib/new-workspace', () => ({ + CLIENT_PLATFORM: 'win32' +})) + +type RuntimePreference = { kind: 'windows-host' } | { kind: 'wsl'; distro: string } + +function makeState(args: { + worktreePath: string + localWindowsRuntimePreference?: RuntimePreference +}): Pick< + AppState, + 'activeRepoId' | 'activeWorktreeId' | 'projects' | 'repos' | 'settings' | 'worktreesByRepo' +> { + return { + activeRepoId: 'repo-1', + activeWorktreeId: 'repo-1::worktree-1', + repos: [{ id: 'repo-1', path: 'C:\\Users\\alice\\repo' }], + projects: [ + { + id: 'repo-1', + sourceRepoIds: ['repo-1'], + ...(args.localWindowsRuntimePreference + ? { localWindowsRuntimePreference: args.localWindowsRuntimePreference } + : {}) + } + ], + settings: { localWindowsRuntimeDefault: { kind: 'windows-host' } }, + worktreesByRepo: { + 'repo-1': [ + { + id: 'repo-1::worktree-1', + repoId: 'repo-1', + path: args.worktreePath + } + ] + } + } as unknown as Pick< + AppState, + 'activeRepoId' | 'activeWorktreeId' | 'projects' | 'repos' | 'settings' | 'worktreesByRepo' + > +} + +describe('ai vault resume command runtime', () => { + it('uses Windows command wrapping for Windows-host projects', () => { + const state = makeState({ worktreePath: 'C:\\Users\\alice\\repo' }) + + expect( + buildAiVaultResumeCommandForWorktree({ + state, + worktreeId: 'repo-1::worktree-1', + session: { + agent: 'claude', + sessionId: 'session one', + cwd: 'C:\\Users\\alice\\repo', + codexHome: null + } + }) + ).toBe('cmd /d /s /c "cd /d ""C:\\Users\\alice\\repo"" && claude --resume ""session one"""') + }) + + it('uses POSIX command wrapping for Windows-path projects forced to WSL', () => { + const state = makeState({ + worktreePath: 'C:\\Users\\alice\\repo', + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + }) + + expect(getAiVaultResumePlatform(state, 'repo-1::worktree-1')).toBe('linux') + expect( + buildAiVaultResumeCommandForWorktree({ + state, + worktreeId: 'repo-1::worktree-1', + session: { + agent: 'claude', + sessionId: 'session one', + cwd: '/home/alice/repo', + codexHome: null + } + }) + ).toBe("cd '/home/alice/repo' && claude --resume 'session one'") + }) + + it('keeps WSL UNC worktrees on POSIX command wrapping without an explicit override', () => { + const state = makeState({ + worktreePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo' + }) + + expect(getAiVaultResumePlatform(state, 'repo-1::worktree-1')).toBe('linux') + }) +}) diff --git a/src/renderer/src/lib/ai-vault-resume-command.ts b/src/renderer/src/lib/ai-vault-resume-command.ts new file mode 100644 index 00000000000..112e00d8a77 --- /dev/null +++ b/src/renderer/src/lib/ai-vault-resume-command.ts @@ -0,0 +1,51 @@ +import { buildAiVaultResumeCommand, type AiVaultSession } from '../../../shared/ai-vault-types' +import { parseWslUncPath } from '../../../shared/wsl-paths' +import type { AppState } from '@/store/types' +import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' +import { CLIENT_PLATFORM } from '@/lib/new-workspace' + +type AiVaultResumeCommandSession = Pick + +export function buildAiVaultResumeCommandForWorktree(args: { + state: Pick< + AppState, + 'activeRepoId' | 'activeWorktreeId' | 'projects' | 'repos' | 'settings' | 'worktreesByRepo' + > + worktreeId?: string | null + session: AiVaultResumeCommandSession + commandOverride?: string | null +}): string { + const platform = getAiVaultResumePlatform(args.state, args.worktreeId) + return buildAiVaultResumeCommand({ + agent: args.session.agent, + sessionId: args.session.sessionId, + cwd: args.session.cwd, + platform, + commandOverride: args.commandOverride, + codexHome: args.session.codexHome + }) +} + +export function getAiVaultResumePlatform( + state: Pick< + AppState, + 'activeRepoId' | 'activeWorktreeId' | 'projects' | 'repos' | 'settings' | 'worktreesByRepo' + >, + worktreeId?: string | null +): NodeJS.Platform { + const projectRuntime = getLocalProjectExecutionRuntimeContext(state, worktreeId, CLIENT_PLATFORM) + if (projectRuntime?.status === 'repair-required') { + return projectRuntime.repair.preferredRuntime.kind === 'wsl' ? 'linux' : CLIENT_PLATFORM + } + if (projectRuntime?.status === 'resolved' && projectRuntime.runtime.kind === 'wsl') { + return 'linux' + } + + const targetWorktreeId = worktreeId ?? state.activeWorktreeId + const worktree = targetWorktreeId + ? Object.values(state.worktreesByRepo ?? {}) + .flat() + .find((candidate) => candidate.id === targetWorktreeId) + : null + return worktree?.path && parseWslUncPath(worktree.path) ? 'linux' : CLIENT_PLATFORM +} diff --git a/src/renderer/src/lib/fix-checks-agent-launch.test.ts b/src/renderer/src/lib/fix-checks-agent-launch.test.ts index 7aaf8b6f549..1cd10f8114c 100644 --- a/src/renderer/src/lib/fix-checks-agent-launch.test.ts +++ b/src/renderer/src/lib/fix-checks-agent-launch.test.ts @@ -20,6 +20,25 @@ const mocks = vi.hoisted(() => { path: '/repo/wt-1' } ], + worktreesByRepo: { + 'repo-1': [ + { + id: 'wt-1', + repoId: 'repo-1', + path: '/repo/wt-1' + } + ] + }, + projects: [ + { + id: 'repo-1', + displayName: 'Repo', + badgeColor: '#000000', + sourceRepoIds: ['repo-1'], + createdAt: 1, + updatedAt: 1 + } + ] as Record[], allWorktrees: vi.fn(() => store.worktrees), ensureDetectedAgents: vi.fn(), ensureRemoteDetectedAgents: vi.fn() @@ -74,6 +93,10 @@ vi.mock('@/lib/launch-work-item-direct', () => ({ launchWorkItemDirect: mocks.launchWorkItemDirect })) +vi.mock('@/lib/new-workspace', () => ({ + CLIENT_PLATFORM: 'win32' +})) + vi.mock('@/lib/source-control-launch-agent-selection', () => ({ pickSourceControlLaunchAgent: mocks.pickSourceControlLaunchAgent, readSourceControlLaunchRecipeAgentId: mocks.readSourceControlLaunchRecipeAgentId @@ -94,7 +117,28 @@ vi.mock('../../../shared/source-control-ai', () => ({ describe('startFixChecksAgent', () => { beforeEach(() => { vi.clearAllMocks() + mocks.store.repos = [ + { + id: 'repo-1', + path: '/repo', + displayName: 'Repo', + badgeColor: '#000000', + addedAt: 1, + connectionId: null + } + ] mocks.store.worktrees = [{ id: 'wt-1', repoId: 'repo-1', path: '/repo/wt-1' }] + mocks.store.worktreesByRepo = { 'repo-1': mocks.store.worktrees } + mocks.store.projects = [ + { + id: 'repo-1', + displayName: 'Repo', + badgeColor: '#000000', + sourceRepoIds: ['repo-1'], + createdAt: 1, + updatedAt: 1 + } + ] mocks.store.ensureDetectedAgents.mockResolvedValue(['codex']) mocks.store.ensureRemoteDetectedAgents.mockResolvedValue(['codex']) mocks.activateAndRevealWorktree.mockReturnValue(true) @@ -183,10 +227,66 @@ describe('startFixChecksAgent', () => { expect(mocks.store.ensureRemoteDetectedAgents).toHaveBeenCalledWith('ssh-1') expect(mocks.resolveSourceControlLaunchPlatform).toHaveBeenCalledWith({ connectionId: 'ssh-1', - worktreePath: '/repo/wt-1' + worktreePath: '/repo/wt-1', + projectRuntime: undefined }) }) + it('passes the local project runtime when resolving an attached WSL workspace launch platform', async () => { + mocks.store.repos = [ + { + ...mocks.store.repos[0], + path: 'C:\\Users\\alice\\repo' + } + ] + mocks.store.worktrees = [ + { + id: 'wt-1', + repoId: 'repo-1', + path: 'C:\\Users\\alice\\repo-worktree' + } + ] + mocks.store.worktreesByRepo = { 'repo-1': mocks.store.worktrees } + mocks.store.projects = [ + { + id: 'repo-1', + displayName: 'Repo', + badgeColor: '#000000', + sourceRepoIds: ['repo-1'], + createdAt: 1, + updatedAt: 1, + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + } + ] + mocks.resolveSourceControlLaunchPlatform.mockImplementation(({ projectRuntime }) => + projectRuntime?.status === 'resolved' && projectRuntime.runtime.kind === 'wsl' + ? 'linux' + : 'win32' + ) + const { startFixChecksAgent } = await import('./fix-checks-agent-launch') + + await expect( + startFixChecksAgent({ + repoId: 'repo-1', + worktreeId: 'wt-1', + basePrompt: 'Fix checks', + launchSource: 'task_page' + }) + ).resolves.toBe(true) + + expect(mocks.resolveSourceControlLaunchPlatform).toHaveBeenCalledWith({ + connectionId: null, + worktreePath: 'C:\\Users\\alice\\repo-worktree', + projectRuntime: expect.objectContaining({ + status: 'resolved', + runtime: expect.objectContaining({ kind: 'wsl', distro: 'Ubuntu' }) + }) + }) + expect(mocks.launchAgentInNewTab).toHaveBeenCalledWith( + expect.objectContaining({ launchPlatform: 'linux' }) + ) + }) + it('fails without launching when the launch platform cannot be resolved', async () => { mocks.resolveSourceControlLaunchPlatform.mockReturnValue(undefined) const { startFixChecksAgent } = await import('./fix-checks-agent-launch') diff --git a/src/renderer/src/lib/fix-checks-agent-launch.ts b/src/renderer/src/lib/fix-checks-agent-launch.ts index 65db5771090..49ad2ccb856 100644 --- a/src/renderer/src/lib/fix-checks-agent-launch.ts +++ b/src/renderer/src/lib/fix-checks-agent-launch.ts @@ -4,6 +4,8 @@ import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { findGithubPrWorkspaceAttachment } from '@/lib/github-work-item-workspace-attachment' import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' import { launchWorkItemDirect } from '@/lib/launch-work-item-direct' +import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' +import { CLIENT_PLATFORM } from '@/lib/new-workspace' import { planAgentCliArgsSuffix } from '@/lib/tui-agent-startup' import { pickSourceControlLaunchAgent, @@ -164,7 +166,10 @@ export async function startFixChecksAgent(args: StartFixChecksAgentArgs): Promis } const launchPlatform = resolveSourceControlLaunchPlatform({ connectionId: targetConnectionId, - worktreePath: targetWorktree.path + worktreePath: targetWorktree.path, + projectRuntime: targetConnectionId + ? undefined + : getLocalProjectExecutionRuntimeContext(store, targetWorktreeId, CLIENT_PLATFORM) }) if (!launchPlatform) { toast.error( diff --git a/src/renderer/src/lib/launch-agent-background-session.test.ts b/src/renderer/src/lib/launch-agent-background-session.test.ts index 8d5b0851bc4..11fea568fc6 100644 --- a/src/renderer/src/lib/launch-agent-background-session.test.ts +++ b/src/renderer/src/lib/launch-agent-background-session.test.ts @@ -32,11 +32,34 @@ function expectStablePaneSpawn(): string { } const state = { + activeRepoId: 'repo-1', + activeWorktreeId: 'wt-1', settings: { agentCmdOverrides: {}, activeRuntimeEnvironmentId: null as string | null }, - repos: [{ id: 'repo-1', connectionId: null as string | null }], - allWorktrees: vi.fn(() => [ - { id: 'wt-1', repoId: 'repo-1', path: '/repo/worktree', displayName: 'main' } - ]), + projects: [ + { + id: 'repo-1', + localWindowsRuntimePreference: { kind: 'inherit-global' as const } + } + ] as { + id: string + localWindowsRuntimePreference: + | { kind: 'inherit-global' } + | { kind: 'windows-host' } + | { kind: 'wsl'; distro: string | null } + }[], + repos: [{ id: 'repo-1', connectionId: null as string | null, path: '/repo' }], + worktreesByRepo: { + 'repo-1': [ + { + id: 'wt-1', + repoId: 'repo-1', + projectId: 'repo-1', + path: '/repo/worktree', + displayName: 'main' + } + ] + }, + allWorktrees: vi.fn(() => state.worktreesByRepo['repo-1']), createTab: mockCreateTab, setTabCustomTitle: mockSetTabCustomTitle, updateTabPtyId: mockUpdateTabPtyId, @@ -75,8 +98,27 @@ describe('launchAgentBackgroundSession', () => { (args) => createCompatibleRuntimeStatusResponseIfNeeded(args) ?? mockRuntimeEnvironmentCall(args) ) + state.activeRepoId = 'repo-1' + state.activeWorktreeId = 'wt-1' state.settings = { agentCmdOverrides: {}, activeRuntimeEnvironmentId: null } - state.repos = [{ id: 'repo-1', connectionId: null }] + state.projects = [ + { + id: 'repo-1', + localWindowsRuntimePreference: { kind: 'inherit-global' } + } + ] + state.repos = [{ id: 'repo-1', connectionId: null, path: '/repo' }] + state.worktreesByRepo = { + 'repo-1': [ + { + id: 'wt-1', + repoId: 'repo-1', + projectId: 'repo-1', + path: '/repo/worktree', + displayName: 'main' + } + ] + } mockCreateTab.mockReturnValue({ id: 'tab-1', title: 'Terminal 1' }) mockSpawn.mockResolvedValue({ id: 'pty-1' }) mockRuntimeEnvironmentCall.mockResolvedValue({ @@ -157,6 +199,45 @@ describe('launchAgentBackgroundSession', () => { expect(result).toMatchObject({ tabId: 'tab-1', ptyId: 'pty-1' }) }) + it('uses WSL launch quoting for Windows-path projects forced to WSL', async () => { + state.projects = [ + { + id: 'repo-1', + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + } + ] + state.repos = [{ id: 'repo-1', connectionId: null, path: 'C:\\Users\\jinwo\\repo' }] + state.worktreesByRepo = { + 'repo-1': [ + { + id: 'wt-1', + repoId: 'repo-1', + projectId: 'repo-1', + path: 'C:\\Users\\jinwo\\repo\\feature', + displayName: 'feature' + } + ] + } + + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + await launchAgentBackgroundSession({ + agent: 'claude', + worktreeId: 'wt-1', + prompt: "don't use powershell quoting" + }) + + expect(mockSpawn).toHaveBeenCalledWith( + expect.objectContaining({ + cwd: 'C:\\Users\\jinwo\\repo\\feature', + command: "claude '--dangerously-skip-permissions' 'don'\\''t use powershell quoting'", + connectionId: null, + worktreeId: 'wt-1', + tabId: 'tab-1' + }) + ) + }) + it('pre-marks trust for agents with first-launch trust prompts', async () => { const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') @@ -277,7 +358,7 @@ describe('launchAgentBackgroundSession', () => { it('injects startup commands into SSH background sessions after shell output arrives', async () => { vi.useFakeTimers() try { - state.repos = [{ id: 'repo-1', connectionId: 'ssh-1' }] + state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }] const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') await launchAgentBackgroundSession({ diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index 4c8b95901e2..1977bc9c506 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -1,9 +1,11 @@ import { toast } from 'sonner' import { useAppStore } from '@/store' import { buildAgentStartupPlan, type AgentStartupPlan } from '@/lib/tui-agent-startup' +import { getAgentLaunchPlatformForRepo } from '@/lib/agent-launch-platform' import { CLIENT_PLATFORM } from '@/lib/new-workspace' import { track, tuiAgentToAgentKind } from '@/lib/telemetry' import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft' +import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { resolveTuiAgentLaunchArgs, resolveTuiAgentLaunchEnv @@ -73,6 +75,12 @@ export async function launchAgentBackgroundSession( const cmdOverrides = store.settings?.agentCmdOverrides ?? {} const agentArgs = resolveTuiAgentLaunchArgs(agent, store.settings?.agentDefaultArgs) const agentEnv = resolveTuiAgentLaunchEnv(agent, store.settings?.agentDefaultEnv) + const launchPlatform = repo + ? getAgentLaunchPlatformForRepo( + repo, + repo.connectionId ? undefined : getLocalProjectExecutionRuntimeContext(store, worktreeId) + ) + : CLIENT_PLATFORM const trimmedPrompt = prompt?.trim() ?? '' const hasPrompt = trimmedPrompt.length > 0 const isFollowupPath = TUI_AGENT_CONFIG[agent].promptInjectionMode === 'stdin-after-start' @@ -86,7 +94,7 @@ export async function launchAgentBackgroundSession( cmdOverrides, agentArgs, agentEnv, - platform: CLIENT_PLATFORM, + platform: launchPlatform, allowEmptyPromptLaunch: true }) pasteDraftAfterLaunch = trimmedPrompt @@ -97,7 +105,7 @@ export async function launchAgentBackgroundSession( cmdOverrides, agentArgs, agentEnv, - platform: CLIENT_PLATFORM, + platform: launchPlatform, allowEmptyPromptLaunch: !hasPrompt }) } diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts index d9f399968b3..9e34e0a3e98 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts @@ -11,7 +11,34 @@ const mockTrack = vi.fn() const LEAF_ID = '11111111-1111-4111-8111-111111111111' const store = { + activeRepoId: 'repo-1', + activeWorktreeId: 'wt-1', settings: { agentCmdOverrides: {}, activeRuntimeEnvironmentId: null as string | null }, + projects: [ + { + id: 'repo-1', + localWindowsRuntimePreference: { kind: 'inherit-global' as const } + } + ] as { + id: string + localWindowsRuntimePreference: + | { kind: 'inherit-global' } + | { kind: 'windows-host' } + | { kind: 'wsl'; distro: string | null } + }[], + repos: [{ id: 'repo-1', connectionId: null as string | null, path: '/repo' }], + worktreesByRepo: { + 'repo-1': [ + { + id: 'wt-1', + repoId: 'repo-1', + projectId: 'repo-1', + path: '/repo/worktree', + displayName: 'main' + } + ] + }, + allWorktrees: vi.fn(() => store.worktreesByRepo['repo-1']), tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] }, @@ -72,7 +99,27 @@ describe('launchAgentInNewTab', () => { vi.clearAllMocks() mockIsWebRuntimeSessionActive.mockReturnValue(false) mockCreateWebRuntimeSessionTerminal.mockResolvedValue(true) + store.activeRepoId = 'repo-1' + store.activeWorktreeId = 'wt-1' store.settings = { agentCmdOverrides: {}, activeRuntimeEnvironmentId: null } + store.projects = [ + { + id: 'repo-1', + localWindowsRuntimePreference: { kind: 'inherit-global' } + } + ] + store.repos = [{ id: 'repo-1', connectionId: null, path: '/repo' }] + store.worktreesByRepo = { + 'repo-1': [ + { + id: 'wt-1', + repoId: 'repo-1', + projectId: 'repo-1', + path: '/repo/worktree', + displayName: 'main' + } + ] + } store.tabsByWorktree = { 'wt-1': [{ id: 'tab-1' }] } store.openFiles = [] store.browserTabsByWorktree = {} @@ -229,6 +276,42 @@ describe('launchAgentInNewTab', () => { ) }) + it('uses WSL launch quoting by default for Windows-path projects forced to WSL', async () => { + store.projects = [ + { + id: 'repo-1', + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + } + ] + store.repos = [{ id: 'repo-1', connectionId: null, path: 'C:\\Users\\jinwo\\repo' }] + store.worktreesByRepo = { + 'repo-1': [ + { + id: 'wt-1', + repoId: 'repo-1', + projectId: 'repo-1', + path: 'C:\\Users\\jinwo\\repo\\feature', + displayName: 'feature' + } + ] + } + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ + agent: 'claude', + worktreeId: 'wt-1', + prompt: "review Bob's change", + promptDelivery: 'draft' + }) + + expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( + 'tab-1', + expect.objectContaining({ + command: "claude '--dangerously-skip-permissions' --prefill 'review Bob'\\''s change'" + }) + ) + }) + it('falls back to post-ready draft paste when a Windows inline draft would be too large', async () => { const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') const prompt = 'x'.repeat(25_000) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index 5687b23a004..b9e752c9746 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -7,10 +7,12 @@ import { type AgentStartupPlan } from '@/lib/tui-agent-startup' import { CLIENT_PLATFORM } from '@/lib/new-workspace' +import { getAgentLaunchPlatformForRepo } from '@/lib/agent-launch-platform' import { reconcileTabOrder } from '@/components/tab-bar/reconcile-order' import { track, tuiAgentToAgentKind } from '@/lib/telemetry' import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { createWebRuntimeSessionTerminal, isWebRuntimeSessionActive, @@ -135,10 +137,20 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI promptDelivery = 'auto-submit', launchSource, quickCommandLabel, - launchPlatform = CLIENT_PLATFORM, + launchPlatform, onPromptDelivered } = args const store = useAppStore.getState() + const worktree = store.allWorktrees?.().find((entry: { id: string }) => entry.id === worktreeId) + const repo = worktree ? store.repos?.find((entry) => entry.id === worktree.repoId) : null + const resolvedLaunchPlatform = + launchPlatform ?? + (repo + ? getAgentLaunchPlatformForRepo( + repo, + repo.connectionId ? undefined : getLocalProjectExecutionRuntimeContext(store, worktreeId) + ) + : CLIENT_PLATFORM) const cmdOverrides = store.settings?.agentCmdOverrides ?? {} const effectiveAgentArgs = agentArgs !== undefined @@ -166,7 +178,7 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI agent, prompt: '', cmdOverrides, - platform: launchPlatform, + platform: resolvedLaunchPlatform, agentArgs: effectiveAgentArgs, agentEnv, allowEmptyPromptLaunch: true @@ -179,11 +191,11 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI agent, draft: trimmedPrompt, cmdOverrides, - platform: launchPlatform, + platform: resolvedLaunchPlatform, agentArgs: effectiveAgentArgs, agentEnv }) - if (draftLaunchPlan && canUseInlineDraftLaunchPlan(draftLaunchPlan, launchPlatform)) { + if (draftLaunchPlan && canUseInlineDraftLaunchPlan(draftLaunchPlan, resolvedLaunchPlatform)) { startupPlan = { agent: draftLaunchPlan.agent, launchCommand: draftLaunchPlan.launchCommand, @@ -196,7 +208,7 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI agent, prompt: '', cmdOverrides, - platform: launchPlatform, + platform: resolvedLaunchPlatform, agentArgs: effectiveAgentArgs, agentEnv, allowEmptyPromptLaunch: true @@ -208,7 +220,7 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI agent, prompt: '', cmdOverrides, - platform: launchPlatform, + platform: resolvedLaunchPlatform, agentArgs: effectiveAgentArgs, agentEnv, allowEmptyPromptLaunch: true @@ -219,7 +231,7 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI agent, prompt: hasPrompt ? trimmedPrompt : '', cmdOverrides, - platform: launchPlatform, + platform: resolvedLaunchPlatform, agentArgs: effectiveAgentArgs, agentEnv, allowEmptyPromptLaunch: !hasPrompt diff --git a/src/renderer/src/lib/launch-work-item-direct-draft.ts b/src/renderer/src/lib/launch-work-item-direct-draft.ts new file mode 100644 index 00000000000..0de3e102b9f --- /dev/null +++ b/src/renderer/src/lib/launch-work-item-direct-draft.ts @@ -0,0 +1,13 @@ +import { isOrcaCliAvailableForLaunch } from '@/lib/orca-cli-launch-availability' +import { getLaunchableWorkItemDraftContent } from '@/lib/linked-work-item-context' +import type { LaunchableWorkItem } from '@/lib/launch-work-item-direct-types' + +export async function getDirectWorkItemDraftContent( + item: LaunchableWorkItem, + repoConnectionId: string | null +): Promise { + const cliAvailable = item.linearIdentifier + ? await isOrcaCliAvailableForLaunch({ remote: repoConnectionId !== null }) + : false + return getLaunchableWorkItemDraftContent({ ...item, cliAvailable }) +} diff --git a/src/renderer/src/lib/launch-work-item-direct.test.ts b/src/renderer/src/lib/launch-work-item-direct.test.ts index 8b1ad3069b4..4d1b4930c24 100644 --- a/src/renderer/src/lib/launch-work-item-direct.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct.test.ts @@ -163,6 +163,19 @@ describe('launchWorkItemDirect', () => { addedAt: 1 } ], + activeRepoId: 'repo-1', + activeWorktreeId: null, + projects: [ + { + id: 'repo-1', + displayName: 'Repo', + badgeColor: '#000000', + sourceRepoIds: ['repo-1'], + createdAt: 1, + updatedAt: 1 + } + ], + worktreesByRepo: {}, settings: { defaultTuiAgent: 'codex', disabledTuiAgents: [], @@ -478,4 +491,57 @@ describe('launchWorkItemDirect', () => { const activationOptions = mocks.activateAndRevealWorktree.mock.calls.at(-1)?.[1] expect(activationOptions.startup.command).toContain('unset ORCA_PI_PREFILL') }) + + it('plans direct local Windows-path launches with POSIX startup for WSL project runtime', async () => { + mocks.store.repos = [ + { + id: 'repo-1', + path: 'C:\\Users\\alice\\repo', + displayName: 'Repo', + addedAt: 1 + } + ] + mocks.store.projects = [ + { + id: 'repo-1', + displayName: 'Repo', + badgeColor: '#000000', + sourceRepoIds: ['repo-1'], + createdAt: 1, + updatedAt: 1, + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + } + ] + mocks.store.createWorktree.mockResolvedValue({ + worktree: { + id: 'repo-1::C:\\Users\\alice\\repo-worktree', + path: 'C:\\Users\\alice\\repo-worktree' + } + }) + const { launchWorkItemDirect } = await import('./launch-work-item-direct') + + await expect( + launchWorkItemDirect({ + item: { + title: 'Fix failing checks', + url: 'https://github.com/acme/repo/pull/1', + type: 'issue', + number: 1, + pasteContent: 'Fix the failing checks.' + }, + repoId: 'repo-1', + openModalFallback: mocks.openModalFallback, + launchSource: 'task_page', + agentOverride: 'codex', + promptDelivery: 'submit-after-ready' + }) + ).resolves.toBe(true) + + expect(buildAgentStartupPlan).toHaveBeenCalledWith( + expect.objectContaining({ + agent: 'codex', + platform: 'linux' + }) + ) + }) }) diff --git a/src/renderer/src/lib/launch-work-item-direct.ts b/src/renderer/src/lib/launch-work-item-direct.ts index 6724d6c1576..1574ae19c3f 100644 --- a/src/renderer/src/lib/launch-work-item-direct.ts +++ b/src/renderer/src/lib/launch-work-item-direct.ts @@ -12,9 +12,7 @@ import { import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' import { isTuiAgentEnabled, pickTuiAgent } from '../../../shared/tui-agent-selection' import { activateAndRevealWorktree } from '@/lib/worktree-activation' -import { getWorkspaceIntentName, getWorkspaceSeedName } from '@/lib/new-workspace' -import { getLaunchableWorkItemDraftContent } from '@/lib/linked-work-item-context' -import { isOrcaCliAvailableForLaunch } from '@/lib/orca-cli-launch-availability' +import { CLIENT_PLATFORM, getWorkspaceIntentName, getWorkspaceSeedName } from '@/lib/new-workspace' import { agentLaunchCommandErrorMessage, gitLabIssueNumber, @@ -30,31 +28,23 @@ import { buildDirectWorkItemStartupOpts, pasteDirectWorkItemDraftWhenAgentReady } from '@/lib/launch-work-item-direct-agent' +import { getDirectWorkItemDraftContent } from '@/lib/launch-work-item-direct-draft' import { resolveDirectPrStartPoint, resolveDirectSetupDecision } from '@/lib/launch-work-item-direct-preflight' -import type { - LaunchableWorkItem, - LaunchWorkItemDirectArgs -} from '@/lib/launch-work-item-direct-types' +import type { LaunchWorkItemDirectArgs } from '@/lib/launch-work-item-direct-types' import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform' import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' +import { + getLocalProjectExecutionRuntimeContext, + getLocalRepoProjectExecutionRuntimeContext +} from '@/lib/local-preflight-context' // Why: bracketed paste markers and ready-wait grace timing live in // agent-paste-draft.ts so the new-workspace and "Use" flows share one // definition of "type into the agent's input as a non-submitted draft". -async function getDirectDraftContent( - item: LaunchableWorkItem, - repoConnectionId: string | null -): Promise { - const cliAvailable = item.linearIdentifier - ? await isOrcaCliAvailableForLaunch({ remote: repoConnectionId !== null }) - : false - return getLaunchableWorkItemDraftContent({ ...item, cliAvailable }) -} - /** * "Use" flow: create the workspace, activate it, launch the default agent, * and paste the work item context into the agent. Most callers leave it as a draft; @@ -91,11 +81,15 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom const repoOwnerSettings = getSettingsForRepoRuntimeOwner(store, repoId) const promptDelivery = args.promptDelivery ?? 'draft' const repoConnectionId = repo.connectionId?.trim() || null + const repoProjectRuntime = repoConnectionId + ? undefined + : getLocalRepoProjectExecutionRuntimeContext(store, repoId, CLIENT_PLATFORM) const preflightLaunchPlatform = args.launchPlatform ?? resolveSourceControlLaunchPlatform({ connectionId: repoConnectionId, - worktreePath: repo.path + worktreePath: repo.path, + projectRuntime: repoProjectRuntime }) const agentArgsPlan = planAgentCliArgsSuffix( agentArgs, @@ -165,7 +159,7 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom let startupPlan: ReturnType = null let effectiveAgent: TuiAgent | null = null let draftLaunchedNatively = false - const draftContent = await getDirectDraftContent(item, repoConnectionId) + const draftContent = await getDirectWorkItemDraftContent(item, repoConnectionId) let startupPlanFailed = false try { const result = await store.createWorktree( @@ -202,13 +196,18 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom // Why: newly-created SSH worktrees can be activated before the store // rehydrates their repo link; preserve the source repo connection. const launchConnectionId = createdConnectionId ?? repoConnectionId + const latestStore = useAppStore.getState() const launchPlatform = args.launchPlatform ?? resolveSourceControlLaunchPlatform({ connectionId: launchConnectionId, - worktreePath + worktreePath, + projectRuntime: + launchConnectionId === null + ? (getLocalProjectExecutionRuntimeContext(latestStore, worktreeId, CLIENT_PLATFORM) ?? + repoProjectRuntime) + : undefined }) - const latestStore = useAppStore.getState() if (agentOverride) { const detectedAgents = typeof launchConnectionId === 'string' diff --git a/src/renderer/src/lib/local-preflight-context.test.ts b/src/renderer/src/lib/local-preflight-context.test.ts index e53f22365d1..16e5e393d62 100644 --- a/src/renderer/src/lib/local-preflight-context.test.ts +++ b/src/renderer/src/lib/local-preflight-context.test.ts @@ -1,13 +1,21 @@ import { describe, expect, it } from 'vitest' +import type { Repo, Worktree } from '../../../shared/types' import type { AppState } from '@/store/types' import { getLocalAgentPreflightContext, getLocalPreflightContext, + getLocalProjectExecutionRuntimeContext, + getLocalRepoProjectExecutionRuntimeContext, getWslDistroFromPath, localPreflightContextKey } from './local-preflight-context' -function makeState(args: { repoPath?: string | null; worktreePath?: string | null }): AppState { +function makeState(args: { + repoPath?: string | null + worktreePath?: string | null + repo?: Partial + worktree?: Partial +}): AppState { const repoId = 'repo-1' const worktreeId = `${repoId}::worktree-1` return { @@ -19,7 +27,8 @@ function makeState(args: { repoPath?: string | null; worktreePath?: string | nul : [ { id: repoId, - path: args.repoPath + path: args.repoPath, + ...args.repo } ], worktreesByRepo: @@ -30,7 +39,8 @@ function makeState(args: { repoPath?: string | null; worktreePath?: string | nul { id: worktreeId, repoId, - path: args.worktreePath + path: args.worktreePath, + ...args.worktree } ] } @@ -46,11 +56,12 @@ describe('local preflight context', () => { it('returns a stable snapshot for repeated WSL selector reads', () => { const state = makeState({ + repoPath: '/Users/alice/repo', worktreePath: String.raw`\\wsl.localhost\Ubuntu\home\alice\repo` }) - const first = getLocalPreflightContext(state) - const second = getLocalPreflightContext(state) + const first = getLocalPreflightContext(state, 'darwin') + const second = getLocalPreflightContext(state, 'darwin') expect(first).toBe(second) expect(first).toEqual({ wslDistro: 'Ubuntu' }) @@ -61,18 +72,22 @@ describe('local preflight context', () => { const fromRepo = getLocalPreflightContext( makeState({ repoPath: String.raw`\\wsl.localhost\Ubuntu\home\alice\repo` - }) + }), + 'darwin' ) const fromWorktree = getLocalPreflightContext( makeState({ repoPath: '/Users/alice/repo', worktreePath: String.raw`\\wsl.localhost\Ubuntu\home\alice\repo` - }) + }), + 'darwin' ) const fromOtherDistro = getLocalPreflightContext( makeState({ + repoPath: '/Users/alice/repo', worktreePath: String.raw`\\wsl.localhost\Debian\home\alice\repo` - }) + }), + 'darwin' ) expect(fromRepo).toBe(fromWorktree) @@ -103,7 +118,79 @@ describe('local preflight context', () => { expect(localPreflightContextKey(context)).toBe(context?.runtimeContextKey) }) - it('uses the selected WSL distro for local agent checks when WSL is the default shell', () => { + it('uses the project runtime for Windows local preflight WSL paths', () => { + const state = makeState({ + repoPath: String.raw`\\wsl.localhost\Ubuntu\home\alice\repo` + }) + + const context = getLocalPreflightContext(state, 'win32') + + expect(context).toEqual({ + wslDistro: 'Ubuntu', + projectRuntime: { + status: 'resolved', + runtime: { + kind: 'wsl', + hostPlatform: 'wsl', + projectId: 'repo-1', + distro: 'Ubuntu', + reason: 'project-override', + cacheKey: 'repo-1:wsl:Ubuntu' + } + } + }) + expect(localPreflightContextKey(context)).toBe('repo-1:wsl:Ubuntu') + }) + + it('uses the project runtime for Windows local preflight host paths', () => { + const state = makeState({ repoPath: 'C:\\Users\\alice\\repo' }) + + const context = getLocalPreflightContext(state, 'win32') + + expect(context).toEqual({ + projectRuntime: { + status: 'resolved', + runtime: { + kind: 'windows-host', + hostPlatform: 'win32', + projectId: 'repo-1', + reason: 'global-default', + cacheKey: 'repo-1:windows-host' + } + } + }) + expect(localPreflightContextKey(context)).toBe('repo-1:windows-host') + }) + + it('returns repair context for Windows local preflight when the selected WSL distro is missing', () => { + const state = { + ...makeState({ repoPath: 'C:\\Users\\alice\\repo' }), + settings: { + localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } + } + } as unknown as AppState + + const context = getLocalPreflightContext(state, 'win32', { + wslAvailable: true, + availableWslDistros: ['Debian'] + }) + + expect(context).toEqual({ + projectRuntime: { + status: 'repair-required', + repair: { + projectId: 'repo-1', + preferredRuntime: { kind: 'wsl', distro: 'Ubuntu' }, + reason: 'wsl-distro-missing', + source: 'global-default', + cacheKey: 'repo-1:repair:wsl-distro-missing:Ubuntu' + } + } + }) + expect(localPreflightContextKey(context)).toBe('repo-1:repair:wsl-distro-missing:Ubuntu') + }) + + it('uses the migrated WSL runtime for local agent checks when WSL is the default shell', () => { const state = { ...makeState({ repoPath: 'C:\\Users\\alice\\repo' }), settings: { @@ -112,13 +199,26 @@ describe('local preflight context', () => { } } as AppState - const context = getLocalAgentPreflightContext(state) + const context = getLocalAgentPreflightContext(state, 'win32') - expect(context).toEqual({ wslDistro: 'Debian' }) - expect(localPreflightContextKey(context)).toBe('wsl:Debian') + expect(context).toEqual({ + wslDistro: 'Debian', + projectRuntime: { + status: 'resolved', + runtime: { + kind: 'wsl', + hostPlatform: 'wsl', + projectId: 'repo-1', + distro: 'Debian', + reason: 'global-default', + cacheKey: 'repo-1:wsl:Debian' + } + } + }) + expect(localPreflightContextKey(context)).toBe('repo-1:wsl:Debian') }) - it('lets explicit agent location choose Windows even when the terminal shell is WSL', () => { + it('migrates explicit legacy agent location to the project runtime default', () => { const state = { ...makeState({ repoPath: 'C:\\Users\\alice\\repo' }), settings: { @@ -128,10 +228,21 @@ describe('local preflight context', () => { } } as AppState - const context = getLocalAgentPreflightContext(state) + const context = getLocalAgentPreflightContext(state, 'win32') - expect(context).toBeUndefined() - expect(localPreflightContextKey(context)).toBe('host') + expect(context).toEqual({ + projectRuntime: { + status: 'resolved', + runtime: { + kind: 'windows-host', + hostPlatform: 'win32', + projectId: 'repo-1', + reason: 'global-default', + cacheKey: 'repo-1:windows-host' + } + } + }) + expect(localPreflightContextKey(context)).toBe('repo-1:windows-host') }) it('lets explicit agent location choose a WSL distro independent of the terminal shell', () => { @@ -144,9 +255,330 @@ describe('local preflight context', () => { } } as AppState - const context = getLocalAgentPreflightContext(state) + const context = getLocalAgentPreflightContext(state, 'win32') - expect(context).toEqual({ wslDistro: 'Ubuntu' }) - expect(localPreflightContextKey(context)).toBe('wsl:Ubuntu') + expect(context).toEqual({ + wslDistro: 'Ubuntu', + projectRuntime: { + status: 'resolved', + runtime: { + kind: 'wsl', + hostPlatform: 'wsl', + projectId: 'repo-1', + distro: 'Ubuntu', + reason: 'global-default', + cacheKey: 'repo-1:wsl:Ubuntu' + } + } + }) + expect(localPreflightContextKey(context)).toBe('repo-1:wsl:Ubuntu') + }) + + it('ignores stale terminal WSL settings when no Windows project runtime is available', () => { + const state = { + ...makeState({ repoPath: 'C:\\Users\\alice\\repo' }), + settings: { + terminalWindowsShell: 'wsl.exe', + terminalWindowsWslDistro: 'Debian' + } + } as AppState + + expect(getLocalAgentPreflightContext(state, 'linux')).toBeUndefined() + }) + + it('ignores stale explicit agent runtime settings on non-Windows hosts', () => { + const state = { + ...makeState({ repoPath: 'C:\\Users\\alice\\repo' }), + settings: { + localAgentRuntime: 'wsl', + localAgentWslDistro: 'Ubuntu' + } + } as AppState + + expect(getLocalAgentPreflightContext(state, 'darwin')).toBeUndefined() + }) + + it('returns repair context for explicit WSL agent location without a selected distro', () => { + const state = { + ...makeState({ repoPath: 'C:\\Users\\alice\\repo' }), + settings: { + terminalWindowsShell: 'powershell.exe', + localAgentRuntime: 'wsl' + } + } as unknown as AppState + + const context = getLocalAgentPreflightContext(state, 'win32') + + expect(context).toEqual({ + projectRuntime: { + status: 'repair-required', + repair: { + projectId: 'repo-1', + preferredRuntime: { kind: 'wsl', distro: null }, + reason: 'wsl-distro-required', + source: 'global-default', + cacheKey: 'repo-1:repair:wsl-distro-required:default' + } + } + }) + expect(localPreflightContextKey(context)).toBe('repo-1:repair:wsl-distro-required:default') + }) + + it('uses the project override over legacy agent location for local agent checks', () => { + const state = { + ...makeState({ repoPath: 'C:\\Users\\alice\\repo' }), + settings: { + localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' }, + localAgentRuntime: 'wsl', + localAgentWslDistro: 'Ubuntu' + }, + projects: [{ id: 'repo-1', localWindowsRuntimePreference: { kind: 'windows-host' } }] + } as unknown as AppState + + const context = getLocalAgentPreflightContext(state, 'win32') + + expect(context).toEqual({ + projectRuntime: { + status: 'resolved', + runtime: { + kind: 'windows-host', + hostPlatform: 'win32', + projectId: 'repo-1', + reason: 'project-override', + cacheKey: 'repo-1:windows-host' + } + } + }) + }) + + it('resolves a project host override for a specific worktree over a WSL default', () => { + const state = { + ...makeState({ repoPath: 'C:\\Users\\alice\\repo', worktreePath: 'C:\\Users\\alice\\repo' }), + settings: { + terminalWindowsShell: 'wsl.exe', + terminalWindowsWslDistro: 'Debian', + localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Debian' } + }, + projects: [{ id: 'repo-1', localWindowsRuntimePreference: { kind: 'windows-host' } }] + } as unknown as AppState + + expect(getLocalProjectExecutionRuntimeContext(state, 'repo-1::worktree-1', 'win32')).toEqual({ + status: 'resolved', + runtime: { + kind: 'windows-host', + hostPlatform: 'win32', + projectId: 'repo-1', + reason: 'project-override', + cacheKey: 'repo-1:windows-host' + } + }) + }) + + it('resolves WSL UNC worktrees to their owning distro when the project inherits host default', () => { + const state = makeState({ + repoPath: 'C:\\Users\\alice\\repo', + worktreePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo' + }) + + expect(getLocalProjectExecutionRuntimeContext(state, 'repo-1::worktree-1', 'win32')).toEqual({ + status: 'resolved', + runtime: { + kind: 'wsl', + hostPlatform: 'wsl', + projectId: 'repo-1', + distro: 'Ubuntu', + reason: 'project-override', + cacheKey: 'repo-1:wsl:Ubuntu' + } + }) + }) + + it('resolves a local repo project WSL override before a worktree exists', () => { + const state = { + ...makeState({ repoPath: String.raw`C:\Users\alice\repo` }), + projects: [ + { + id: 'project-1', + sourceRepoIds: ['repo-1'], + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + } + ] + } as unknown as AppState + + expect(getLocalRepoProjectExecutionRuntimeContext(state, 'repo-1', 'win32')).toEqual({ + status: 'resolved', + runtime: { + kind: 'wsl', + hostPlatform: 'wsl', + projectId: 'project-1', + distro: 'Ubuntu', + reason: 'project-override', + cacheKey: 'project-1:wsl:Ubuntu' + } + }) + }) + + it('resolves a local repo WSL UNC path before a worktree exists', () => { + const state = makeState({ + repoPath: String.raw`\\wsl.localhost\Ubuntu\home\alice\repo` + }) + + expect(getLocalRepoProjectExecutionRuntimeContext(state, 'repo-1', 'win32')).toEqual({ + status: 'resolved', + runtime: { + kind: 'wsl', + hostPlatform: 'wsl', + projectId: 'repo-1', + distro: 'Ubuntu', + reason: 'project-override', + cacheKey: 'repo-1:wsl:Ubuntu' + } + }) + }) + + it('does not create a local project runtime for SSH repos', () => { + const state = makeState({ + repoPath: 'C:\\Users\\alice\\repo', + repo: { connectionId: 'builder', executionHostId: 'ssh:builder' } + }) + + expect(getLocalProjectExecutionRuntimeContext(state, undefined, 'win32')).toBeUndefined() + expect(getLocalRepoProjectExecutionRuntimeContext(state, 'repo-1', 'win32')).toBeUndefined() + expect(getLocalPreflightContext(state, 'win32')).toBeUndefined() + }) + + it('uses the requested worktree repo owner before resolving a local project runtime', () => { + const state = { + ...makeState({ + repoPath: 'C:\\Users\\alice\\repo', + worktreePath: 'C:\\Users\\alice\\repo' + }), + repos: [ + { + id: 'repo-1', + path: 'C:\\Users\\alice\\repo', + executionHostId: 'local' + }, + { + id: 'repo-ssh', + path: '/remote/repo', + connectionId: 'builder', + executionHostId: 'ssh:builder' + } + ], + worktreesByRepo: { + 'repo-1': [ + { + id: 'repo-1::worktree-1', + repoId: 'repo-1', + path: 'C:\\Users\\alice\\repo' + } + ], + 'repo-ssh': [ + { + id: 'repo-ssh::worktree-1', + repoId: 'repo-ssh', + path: '/remote/repo', + hostId: 'ssh:builder' + } + ] + } + } as unknown as AppState + + expect(getLocalProjectExecutionRuntimeContext(state, 'repo-ssh::worktree-1', 'win32')).toBe( + undefined + ) + }) + + it('returns repair when the resolved project WSL distro is unavailable', () => { + const state = { + ...makeState({ repoPath: 'C:\\Users\\alice\\repo', worktreePath: 'C:\\Users\\alice\\repo' }), + settings: { + localWindowsRuntimeDefault: { kind: 'windows-host' } + }, + projects: [{ id: 'repo-1', localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } }] + } as unknown as AppState + + expect( + getLocalProjectExecutionRuntimeContext(state, 'repo-1::worktree-1', 'win32', { + wslAvailable: true, + availableWslDistros: ['Debian'] + }) + ).toEqual({ + status: 'repair-required', + repair: { + projectId: 'repo-1', + preferredRuntime: { kind: 'wsl', distro: 'Ubuntu' }, + reason: 'wsl-distro-missing', + source: 'project-override', + cacheKey: 'repo-1:repair:wsl-distro-missing:Ubuntu' + } + }) + }) + + it('defers WSL repair while capability loading leaves distro availability unknown', () => { + const state = { + ...makeState({ repoPath: 'C:\\Users\\alice\\repo', worktreePath: 'C:\\Users\\alice\\repo' }), + settings: { + localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } + } + } as unknown as AppState + + expect( + getLocalProjectExecutionRuntimeContext(state, 'repo-1::worktree-1', 'win32', { + wslAvailable: undefined, + availableWslDistros: null + }) + ).toEqual({ + status: 'resolved', + runtime: { + kind: 'wsl', + hostPlatform: 'wsl', + projectId: 'repo-1', + distro: 'Ubuntu', + reason: 'global-default', + cacheKey: 'repo-1:wsl:Ubuntu' + } + }) + }) + + it('resolves the requested worktree instead of the active worktree', () => { + const state = { + ...makeState({ + repoPath: 'C:\\Users\\alice\\repo', + worktreePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo' + }), + activeWorktreeId: 'repo-1::worktree-1', + worktreesByRepo: { + 'repo-1': [ + { + id: 'repo-1::worktree-1', + repoId: 'repo-1', + path: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo' + }, + { + id: 'repo-1::host-worktree', + repoId: 'repo-1', + path: 'C:\\Users\\alice\\repo' + } + ] + }, + settings: { + localWindowsRuntimeDefault: { kind: 'windows-host' } + } + } as unknown as AppState + + expect(getLocalProjectExecutionRuntimeContext(state, 'repo-1::host-worktree', 'win32')).toEqual( + { + status: 'resolved', + runtime: { + kind: 'windows-host', + hostPlatform: 'win32', + projectId: 'repo-1', + reason: 'global-default', + cacheKey: 'repo-1:windows-host' + } + } + ) }) }) diff --git a/src/renderer/src/lib/local-preflight-context.ts b/src/renderer/src/lib/local-preflight-context.ts index 8f28f805e17..c1a40511e0a 100644 --- a/src/renderer/src/lib/local-preflight-context.ts +++ b/src/renderer/src/lib/local-preflight-context.ts @@ -1,13 +1,40 @@ import type { AppState } from '@/store/types' import { parseWslUncPath } from '../../../shared/wsl-paths' +import { + deriveGlobalWindowsRuntimeDefaultFromLegacySettings, + resolveProjectExecutionRuntime, + type ProjectExecutionRuntimeResolution +} from '../../../shared/project-execution-runtime' +import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import type { Repo, Worktree } from '../../../shared/types' import { getProviderRuntimeContextKey } from './provider-runtime-context' +import { getRendererAppPlatform } from './renderer-app-platform' +import { + getCachedWindowsTerminalCapabilities, + hasCachedWindowsTerminalCapabilities +} from './windows-terminal-capabilities' + +type LocalProjectRuntimeState = Pick< + AppState, + 'activeRepoId' | 'activeWorktreeId' | 'projects' | 'repos' | 'settings' | 'worktreesByRepo' +> + +type LocalProjectRuntimeWslContext = { + wslAvailable?: boolean + availableWslDistros?: readonly string[] | null +} export type LocalPreflightContext = - | { wslDistro?: string | null; wslDefault?: boolean; runtimeContextKey?: string } + | { + wslDistro?: string | null + wslDefault?: boolean + runtimeContextKey?: string + projectRuntime?: ProjectExecutionRuntimeResolution + } | undefined const wslPreflightContextsByDistro = new Map>() -const wslDefaultPreflightContext = Object.freeze({ wslDefault: true }) +const projectRuntimePreflightContextsByKey = new Map>() export function getWslDistroFromPath(path?: string | null): string | null { return path ? (parseWslUncPath(path)?.distro ?? null) : null @@ -26,55 +53,273 @@ function getWslPreflightContext(wslDistro: string): NonNullable { + const cacheKey = getProjectRuntimeContextObjectCacheKey(resolution) + const cached = projectRuntimePreflightContextsByKey.get(cacheKey) + if (cached) { + return cached + } + + const wslDistro = + resolution.status === 'resolved' && resolution.runtime.kind === 'wsl' + ? resolution.runtime.distro + : undefined + // Why: selectors compare by reference; cache each resolved runtime context so + // adding projectRuntime does not reintroduce useSyncExternalStore churn. + const context = Object.freeze({ + ...(wslDistro ? { wslDistro } : {}), + projectRuntime: resolution + }) + projectRuntimePreflightContextsByKey.set(cacheKey, context) + return context +} + +export function getLocalProjectExecutionRuntimeContext( + state: LocalProjectRuntimeState, + worktreeId?: string | null, + appPlatform: NodeJS.Platform = getRendererAppPlatform(), + wslContext: LocalProjectRuntimeWslContext = {} +): ProjectExecutionRuntimeResolution | undefined { + if (appPlatform !== 'win32') { + return undefined + } + + const worktree = getLocalWorktree(state, worktreeId) + const repo = getLocalRuntimeRepoForWorktree(state, worktree) + if (!isLocalRuntimeRepo(repo) || !isLocalRuntimeWorktree(worktree)) { + return undefined + } + const projectId = getLocalPreflightProjectId(state, worktreeId) + const project = getLocalRuntimeProject(state, projectId, repo.id) + const localPath = worktree?.path ?? repo?.path + const worktreeWslDistro = getWslDistroFromPath(localPath) + const projectRuntimePreference = + project?.localWindowsRuntimePreference ?? + (worktreeWslDistro ? { kind: 'wsl', distro: worktreeWslDistro } : { kind: 'inherit-global' }) + + return resolveProjectExecutionRuntime({ + appPlatform, + projectId, + projectRuntimePreference, + globalWindowsRuntimeDefault: + state.settings?.localWindowsRuntimeDefault ?? + deriveGlobalWindowsRuntimeDefaultFromLegacySettings(state.settings).defaultRuntime, + wslAvailable: wslContext.wslAvailable, + availableWslDistros: wslContext.availableWslDistros + }) +} + +export function getLocalRepoProjectExecutionRuntimeContext( + state: LocalProjectRuntimeState, + repoId: string | null | undefined, + appPlatform: NodeJS.Platform = getRendererAppPlatform(), + wslContext: LocalProjectRuntimeWslContext = {} +): ProjectExecutionRuntimeResolution | undefined { + if (appPlatform !== 'win32' || !repoId) { + return undefined + } + + const repo = (state.repos ?? []).find((entry) => entry.id === repoId) + if (!isLocalRuntimeRepo(repo)) { + return undefined + } + const project = getLocalRuntimeProject(state, repoId, repo.id) + const projectId = project?.id ?? repoId + const repoWslDistro = getWslDistroFromPath(repo?.path) + const projectRuntimePreference = + project?.localWindowsRuntimePreference ?? + (repoWslDistro ? { kind: 'wsl', distro: repoWslDistro } : { kind: 'inherit-global' }) + + return resolveProjectExecutionRuntime({ + appPlatform, + projectId, + projectRuntimePreference, + globalWindowsRuntimeDefault: + state.settings?.localWindowsRuntimeDefault ?? + deriveGlobalWindowsRuntimeDefaultFromLegacySettings(state.settings).defaultRuntime, + wslAvailable: wslContext.wslAvailable, + availableWslDistros: wslContext.availableWslDistros + }) +} + +export function getLocalPreflightContext( + state: AppState, + appPlatform: NodeJS.Platform = getRendererAppPlatform(), + wslContext: LocalProjectRuntimeWslContext = getCachedLocalProjectRuntimeWslContext() +): LocalPreflightContext { if (state.settings?.activeRuntimeEnvironmentId?.trim()) { return { runtimeContextKey: getProviderRuntimeContextKey(state.settings) } } + const projectRuntime = getLocalProjectExecutionRuntimeContext( + state, + undefined, + appPlatform, + wslContext + ) + if (projectRuntime) { + return getProjectRuntimePreflightContext(projectRuntime) + } const wslDistro = getLocalPreflightWslDistro(state) return wslDistro ? getWslPreflightContext(wslDistro) : undefined } -export function getLocalAgentPreflightContext(state: AppState): LocalPreflightContext { - const explicitAgentRuntime = state.settings?.localAgentRuntime +export function getLocalAgentPreflightContext( + state: AppState, + appPlatform: NodeJS.Platform = getRendererAppPlatform(), + wslContext: LocalProjectRuntimeWslContext = getCachedLocalProjectRuntimeWslContext() +): LocalPreflightContext { + const projectRuntime = getLocalProjectExecutionRuntimeContext( + state, + undefined, + appPlatform, + wslContext + ) + if (projectRuntime) { + return getProjectRuntimePreflightContext(projectRuntime) + } + + const explicitAgentRuntime = appPlatform === 'win32' ? state.settings?.localAgentRuntime : null if (explicitAgentRuntime === 'host') { - return undefined + return getProjectRuntimePreflightContext( + resolveProjectExecutionRuntime({ + appPlatform: 'win32', + projectId: getLocalPreflightProjectId(state), + projectRuntimePreference: { kind: 'windows-host' }, + globalWindowsRuntimeDefault: deriveGlobalWindowsRuntimeDefaultFromLegacySettings( + state.settings + ).defaultRuntime + }) + ) } if (explicitAgentRuntime === 'wsl') { - const explicitDistro = - state.settings?.localAgentWslDistro?.trim() || - state.settings?.terminalWindowsWslDistro?.trim() + const explicitDistro = state.settings?.localAgentWslDistro?.trim() if (explicitDistro) { - return getWslPreflightContext(explicitDistro) + return getProjectRuntimePreflightContext( + resolveProjectExecutionRuntime({ + appPlatform: 'win32', + projectId: getLocalPreflightProjectId(state), + projectRuntimePreference: { kind: 'wsl', distro: explicitDistro }, + globalWindowsRuntimeDefault: deriveGlobalWindowsRuntimeDefaultFromLegacySettings( + state.settings + ).defaultRuntime + }) + ) } - return wslDefaultPreflightContext + return getProjectRuntimePreflightContext( + resolveProjectExecutionRuntime({ + appPlatform: 'win32', + projectId: getLocalPreflightProjectId(state), + projectRuntimePreference: { kind: 'inherit-global' }, + globalWindowsRuntimeDefault: deriveGlobalWindowsRuntimeDefaultFromLegacySettings( + state.settings + ).defaultRuntime + }) + ) } const wslDistro = getLocalPreflightWslDistro(state) if (wslDistro) { return getWslPreflightContext(wslDistro) } - if (state.settings?.terminalWindowsShell === 'wsl.exe') { - const preferredDistro = state.settings.terminalWindowsWslDistro?.trim() - if (preferredDistro) { - return getWslPreflightContext(preferredDistro) - } - return wslDefaultPreflightContext - } return undefined } +function getCachedLocalProjectRuntimeWslContext(): LocalProjectRuntimeWslContext { + // Why: preflight selectors are synchronous. Reuse an existing capability + // answer when available without spawning WSL probes from store reads. + if (!hasCachedWindowsTerminalCapabilities()) { + return {} + } + const capabilities = getCachedWindowsTerminalCapabilities() + return { + wslAvailable: capabilities.wslAvailable, + availableWslDistros: capabilities.wslDistros + } +} + function getLocalPreflightWslDistro(state: AppState): string | null { - const activeWorktree = state.activeWorktreeId - ? Object.values(state.worktreesByRepo ?? {}) - .flat() - .find((worktree) => worktree.id === state.activeWorktreeId) - : null - const activePath = - activeWorktree?.path ?? (state.repos ?? []).find((repo) => repo.id === state.activeRepoId)?.path + const activeWorktree = getLocalWorktree(state) + const repo = getLocalRuntimeRepoForWorktree(state, activeWorktree) + if (!isLocalRuntimeRepo(repo) || !isLocalRuntimeWorktree(activeWorktree)) { + return null + } + const activePath = activeWorktree?.path ?? repo.path return getWslDistroFromPath(activePath) } +function getLocalRuntimeRepoForWorktree( + state: LocalProjectRuntimeState, + worktree?: Pick | null +): Pick | undefined { + const repoId = worktree?.repoId ?? state.activeRepoId + return repoId ? (state.repos ?? []).find((repo) => repo.id === repoId) : undefined +} + +function isLocalRuntimeRepo( + repo?: Pick | null +): repo is Pick { + if (!repo) { + return false + } + return getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID +} + +function isLocalRuntimeWorktree(worktree?: Pick | null): boolean { + return !worktree?.hostId || worktree.hostId === LOCAL_EXECUTION_HOST_ID +} + +function getLocalRuntimeProject( + state: LocalProjectRuntimeState, + projectId: string, + repoId: string +) { + return state.projects?.find( + (entry) => + entry.id === projectId || entry.id === repoId || entry.sourceRepoIds?.includes(repoId) + ) +} + +function getLocalWorktree( + state: LocalProjectRuntimeState, + worktreeId?: string | null +): Pick | null { + const targetWorktreeId = worktreeId ?? state.activeWorktreeId + return targetWorktreeId + ? (Object.values(state.worktreesByRepo ?? {}) + .flat() + .find((worktree) => worktree.id === targetWorktreeId) ?? null) + : null +} + +function getLocalPreflightProjectId( + state: LocalProjectRuntimeState, + worktreeId?: string | null +): string { + const activeWorktree = getLocalWorktree(state, worktreeId) + return ( + activeWorktree?.projectId ?? activeWorktree?.repoId ?? state.activeRepoId ?? 'local-project' + ) +} + export function localPreflightContextKey(context: LocalPreflightContext): string { + if (context?.projectRuntime) { + return getProjectRuntimeCacheKey(context.projectRuntime) + } if (context?.runtimeContextKey) { return context.runtimeContextKey } diff --git a/src/renderer/src/lib/project-skill-runtime.test.ts b/src/renderer/src/lib/project-skill-runtime.test.ts new file mode 100644 index 00000000000..e683ddba163 --- /dev/null +++ b/src/renderer/src/lib/project-skill-runtime.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest' +import type { ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime' +import { + getProjectAgentSkillRuntime, + getProjectAgentSkillTerminalShellOverride, + getProjectSkillDiscoveryTarget, + getProjectSkillInstallDisabledReason +} from './project-skill-runtime' + +const hostRuntime: ProjectExecutionRuntimeResolution = { + status: 'resolved', + runtime: { + kind: 'windows-host', + hostPlatform: 'win32', + projectId: 'project-1', + reason: 'project-override', + cacheKey: 'project-1:windows-host' + } +} + +const wslRuntime: ProjectExecutionRuntimeResolution = { + status: 'resolved', + runtime: { + kind: 'wsl', + hostPlatform: 'wsl', + projectId: 'project-1', + distro: 'Ubuntu-24.04', + reason: 'project-override', + cacheKey: 'project-1:wsl:Ubuntu-24.04' + } +} + +const repairRuntime: ProjectExecutionRuntimeResolution = { + status: 'repair-required', + repair: { + projectId: 'project-1', + preferredRuntime: { kind: 'wsl', distro: 'Missing' }, + reason: 'wsl-distro-missing', + source: 'project-override', + cacheKey: 'project-1:repair:wsl-distro-missing:Missing' + } +} + +describe('project skill runtime helpers', () => { + it('passes the resolved project runtime through the discovery target', () => { + expect(getProjectSkillDiscoveryTarget(wslRuntime)).toEqual({ projectRuntime: wslRuntime }) + expect(getProjectSkillDiscoveryTarget(undefined)).toBeUndefined() + }) + + it('maps resolved host and WSL project runtimes into setup runtimes', () => { + expect(getProjectAgentSkillRuntime(hostRuntime, 'win32')).toEqual({ + runtime: 'host', + label: 'Windows' + }) + expect(getProjectAgentSkillRuntime(wslRuntime, 'win32')).toEqual({ + runtime: 'wsl', + wslDistro: 'Ubuntu-24.04', + label: 'WSL Ubuntu-24.04' + }) + }) + + it('keeps repair-required WSL projects scoped to their preferred distro', () => { + expect(getProjectAgentSkillRuntime(repairRuntime, 'win32')).toEqual({ + runtime: 'wsl', + wslDistro: 'Missing', + label: 'WSL Missing' + }) + expect(getProjectSkillInstallDisabledReason(repairRuntime)).toContain('unavailable') + }) + + it('forces PowerShell for WSL setup and for host setup when the terminal shell is WSL', () => { + expect( + getProjectAgentSkillTerminalShellOverride( + 'win32', + { terminalWindowsShell: 'wsl.exe' }, + getProjectAgentSkillRuntime(hostRuntime, 'win32') + ) + ).toBe('powershell.exe') + expect( + getProjectAgentSkillTerminalShellOverride( + 'win32', + { terminalWindowsShell: 'pwsh.exe' }, + getProjectAgentSkillRuntime(wslRuntime, 'win32') + ) + ).toBe('powershell.exe') + }) +}) diff --git a/src/renderer/src/lib/project-skill-runtime.ts b/src/renderer/src/lib/project-skill-runtime.ts new file mode 100644 index 00000000000..9af2907de0a --- /dev/null +++ b/src/renderer/src/lib/project-skill-runtime.ts @@ -0,0 +1,88 @@ +import type { ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime' +import type { SkillDiscoveryTarget } from '../../../shared/skills' +import type { GlobalSettings } from '../../../shared/types' +import { translate } from '@/i18n/i18n' + +export type ProjectAgentSkillRuntime = { + runtime: 'host' | 'wsl' + wslDistro?: string | null + label: string +} + +export function getProjectSkillDiscoveryTarget( + projectRuntime: ProjectExecutionRuntimeResolution | undefined +): SkillDiscoveryTarget | undefined { + return projectRuntime ? { projectRuntime } : undefined +} + +export function getProjectAgentSkillRuntime( + projectRuntime: ProjectExecutionRuntimeResolution | undefined, + currentPlatform: NodeJS.Platform +): ProjectAgentSkillRuntime | undefined { + if (!projectRuntime) { + return undefined + } + + if (projectRuntime.status === 'repair-required') { + return getWslAgentSkillRuntime(projectRuntime.repair.preferredRuntime.distro) + } + + if (projectRuntime.runtime.kind === 'wsl') { + return getWslAgentSkillRuntime(projectRuntime.runtime.distro) + } + + return { + runtime: 'host', + label: currentPlatform === 'win32' ? 'Windows' : 'This device' + } +} + +export function getProjectAgentSkillTerminalShellOverride( + currentPlatform: NodeJS.Platform, + settings: Pick | null | undefined, + runtime: ProjectAgentSkillRuntime | undefined +): string | undefined { + if (currentPlatform !== 'win32') { + return undefined + } + if (runtime?.runtime === 'wsl') { + return 'powershell.exe' + } + return settings?.terminalWindowsShell.toLowerCase() === 'wsl.exe' ? 'powershell.exe' : undefined +} + +export function getProjectSkillInstallDisabledReason( + projectRuntime: ProjectExecutionRuntimeResolution | undefined +): string | null { + if (projectRuntime?.status !== 'repair-required') { + return null + } + + switch (projectRuntime.repair.reason) { + case 'wsl-unavailable': + return translate( + 'auto.lib.projectSkillRuntime.wslUnavailable', + 'Project runtime needs WSL before this skill can be installed.' + ) + case 'wsl-distro-required': + return translate( + 'auto.lib.projectSkillRuntime.distroRequired', + 'Select a WSL distro for this project before installing this skill.' + ) + case 'wsl-distro-missing': + return translate( + 'auto.lib.projectSkillRuntime.distroMissing', + 'The selected WSL distro is unavailable. Choose an available distro or switch this project to Windows.' + ) + } +} + +function getWslAgentSkillRuntime(distro: string | null): ProjectAgentSkillRuntime { + return { + runtime: 'wsl', + wslDistro: distro, + label: distro + ? `WSL ${distro}` + : translate('auto.lib.projectSkillRuntime.wslDefault', 'WSL default') + } +} diff --git a/src/renderer/src/lib/renderer-app-platform.ts b/src/renderer/src/lib/renderer-app-platform.ts new file mode 100644 index 00000000000..e036d0690e3 --- /dev/null +++ b/src/renderer/src/lib/renderer-app-platform.ts @@ -0,0 +1,18 @@ +export function getRendererAppPlatform(): NodeJS.Platform { + const preloadPlatform = + typeof window === 'undefined' ? undefined : window.api?.platform?.get?.()?.platform + if (preloadPlatform) { + return preloadPlatform + } + const userAgent = typeof navigator === 'undefined' ? '' : navigator.userAgent + if (userAgent.includes('Windows')) { + return 'win32' + } + if (userAgent.includes('Mac')) { + return 'darwin' + } + if (userAgent) { + return 'linux' + } + return 'win32' +} diff --git a/src/renderer/src/lib/resume-sleeping-agent-session.test.ts b/src/renderer/src/lib/resume-sleeping-agent-session.test.ts index 1d3913d94de..107f9742495 100644 --- a/src/renderer/src/lib/resume-sleeping-agent-session.test.ts +++ b/src/renderer/src/lib/resume-sleeping-agent-session.test.ts @@ -106,4 +106,50 @@ describe('resumeSleepingAgentSessionsForWorktree', () => { expect(state.pendingStartupByTabId[tabs[0]!.id]?.showSessionRestoredBanner).toBe(true) expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined() }) + + it('uses WSL resume quoting for Windows-path projects forced to WSL', () => { + const record = makeRecord({ + providerSession: { key: 'session_id', id: "sess-1's" }, + origin: 'worktree-sleep' + }) + useAppStore.setState({ + activeRepoId: 'repo-1', + activeWorktreeId: 'wt-1', + repos: [{ id: 'repo-1', path: 'C:\\repo', displayName: 'repo', addedAt: 1 }], + projects: [ + { + id: 'repo-1', + sourceRepoIds: ['repo-1'], + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + } + ], + settings: { + localWindowsRuntimeDefault: { kind: 'windows-host' }, + agentCmdOverrides: {} + }, + worktreesByRepo: { + 'repo-1': [ + { + id: 'wt-1', + repoId: 'repo-1', + path: 'C:\\repo', + displayName: 'repo', + branch: 'main' + } + ] + }, + tabsByWorktree: { 'wt-1': [] }, + sleepingAgentSessionsByPaneKey: { [record.paneKey]: record } + } as never) + + const launched = resumeSleepingAgentSessionsForWorktree('wt-1') + + expect(launched).toBe(1) + const state = useAppStore.getState() + const resumedTab = state.tabsByWorktree['wt-1']?.[0] + expect(resumedTab?.launchAgent).toBe('claude') + expect(state.pendingStartupByTabId[resumedTab!.id]?.command).toContain( + "'--resume' 'sess-1'\\''s'" + ) + }) }) diff --git a/src/renderer/src/lib/resume-sleeping-agent-session.ts b/src/renderer/src/lib/resume-sleeping-agent-session.ts index 8dc7e99cfce..793d5650f3e 100644 --- a/src/renderer/src/lib/resume-sleeping-agent-session.ts +++ b/src/renderer/src/lib/resume-sleeping-agent-session.ts @@ -5,6 +5,7 @@ import { buildAgentResumeStartupPlan } from '@/lib/tui-agent-startup' import { tuiAgentToAgentKind } from '@/lib/telemetry' import { reconcileTabOrder } from '@/components/tab-bar/reconcile-order' import { isWslUncPath } from '../../../shared/wsl-paths' +import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { resolveTuiAgentLaunchArgs, resolveTuiAgentLaunchEnv @@ -16,6 +17,13 @@ function getResumeLaunchPlatform(worktreeId: string): NodeJS.Platform { const state = useAppStore.getState() const worktree = state.getKnownWorktreeById(worktreeId) const repo = worktree ? state.repos.find((entry) => entry.id === worktree.repoId) : null + const projectRuntime = getLocalProjectExecutionRuntimeContext(state, worktreeId) + if (projectRuntime?.status === 'repair-required') { + return projectRuntime.repair.preferredRuntime.kind === 'wsl' ? 'linux' : CLIENT_PLATFORM + } + if (projectRuntime?.status === 'resolved' && projectRuntime.runtime.kind === 'wsl') { + return 'linux' + } if (repo?.connectionId || (worktree?.path && isWslUncPath(worktree.path))) { return 'linux' } diff --git a/src/renderer/src/lib/source-control-launch-platform.test.ts b/src/renderer/src/lib/source-control-launch-platform.test.ts index 8c199e1a954..89bf9ce96a0 100644 --- a/src/renderer/src/lib/source-control-launch-platform.test.ts +++ b/src/renderer/src/lib/source-control-launch-platform.test.ts @@ -30,4 +30,26 @@ describe('resolveSourceControlLaunchPlatform', () => { }) ).toBe('win32') }) + + it('uses linux shell planning for Windows paths forced to local WSL runtime', async () => { + const { resolveSourceControlLaunchPlatform } = await import('./source-control-launch-platform') + + expect( + resolveSourceControlLaunchPlatform({ + connectionId: null, + worktreePath: String.raw`C:\Users\alice\repo`, + projectRuntime: { + status: 'resolved', + runtime: { + kind: 'wsl', + hostPlatform: 'wsl', + distro: 'Ubuntu', + reason: 'project-override', + projectId: 'project-1', + cacheKey: 'project-1:wsl:Ubuntu' + } + } + }) + ).toBe('linux') + }) }) diff --git a/src/renderer/src/lib/source-control-launch-platform.ts b/src/renderer/src/lib/source-control-launch-platform.ts index 4c90b92d1a6..43cff0ff7e4 100644 --- a/src/renderer/src/lib/source-control-launch-platform.ts +++ b/src/renderer/src/lib/source-control-launch-platform.ts @@ -1,15 +1,23 @@ import { isWindowsAbsolutePathLike } from '../../../shared/cross-platform-path' import { isWslUncPath } from '../../../shared/wsl-paths' import { CLIENT_PLATFORM } from './new-workspace' +import type { ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime' export function resolveSourceControlLaunchPlatform(args: { connectionId?: string | null worktreePath?: string | null + projectRuntime?: ProjectExecutionRuntimeResolution }): NodeJS.Platform { const path = args.worktreePath?.trim() ?? '' if (typeof args.connectionId === 'string') { return path && isWindowsAbsolutePathLike(path) && !isWslUncPath(path) ? 'win32' : 'linux' } + if (args.projectRuntime?.status === 'repair-required') { + return args.projectRuntime.repair.preferredRuntime.kind === 'wsl' ? 'linux' : CLIENT_PLATFORM + } + if (args.projectRuntime?.status === 'resolved' && args.projectRuntime.runtime.kind === 'wsl') { + return 'linux' + } if (path && isWslUncPath(path)) { return 'linux' } diff --git a/src/renderer/src/lib/windows-terminal-capabilities.test.ts b/src/renderer/src/lib/windows-terminal-capabilities.test.ts index 256a39e0bd8..55af965a537 100644 --- a/src/renderer/src/lib/windows-terminal-capabilities.test.ts +++ b/src/renderer/src/lib/windows-terminal-capabilities.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { getCachedWindowsTerminalCapabilities, + hasCachedWindowsTerminalCapabilities, loadWindowsTerminalCapabilities, refreshWindowsTerminalCapabilities, resetWindowsTerminalCapabilitiesForTests, @@ -60,6 +61,7 @@ describe('windows terminal capabilities', () => { gitBashAvailable: true }) + expect(hasCachedWindowsTerminalCapabilities()).toBe(false) expect(getCachedWindowsTerminalCapabilities()).toEqual({ wslAvailable: false, wslDistros: [], @@ -78,6 +80,7 @@ describe('windows terminal capabilities', () => { isLoading: false } await expect(loadWindowsTerminalCapabilities()).resolves.toEqual(expected) + expect(hasCachedWindowsTerminalCapabilities()).toBe(true) expect(getCachedWindowsTerminalCapabilities()).toEqual(expected) await loadWindowsTerminalCapabilities() diff --git a/src/renderer/src/lib/windows-terminal-capabilities.ts b/src/renderer/src/lib/windows-terminal-capabilities.ts index 13c138e718e..6af6037e0f8 100644 --- a/src/renderer/src/lib/windows-terminal-capabilities.ts +++ b/src/renderer/src/lib/windows-terminal-capabilities.ts @@ -95,6 +95,10 @@ export function getCachedWindowsTerminalCapabilities( return cachedCapabilitiesByOwnerKey.get(ownerKey)?.capabilities ?? UNAVAILABLE_CAPABILITIES } +export function hasCachedWindowsTerminalCapabilities(ownerKey = 'local'): boolean { + return cachedCapabilitiesByOwnerKey.has(ownerKey) +} + export function loadWindowsTerminalCapabilities( options: { force?: boolean diff --git a/src/renderer/src/lib/worktree-activation-created-agent.test.ts b/src/renderer/src/lib/worktree-activation-created-agent.test.ts index 4226d39f565..bc9646ab6bb 100644 --- a/src/renderer/src/lib/worktree-activation-created-agent.test.ts +++ b/src/renderer/src/lib/worktree-activation-created-agent.test.ts @@ -219,6 +219,69 @@ describe('activateAndRevealWorktree created agent reopen', () => { expect(revealWorktreeInSidebar).toHaveBeenCalledWith(worktree.id) }) + it('uses WSL launch quoting when reopening a Windows-path WSL project agent', () => { + const worktree = { + ...makeWorktree(), + path: 'C:\\Users\\jinwo\\repo\\feature' + } + + useAppStore.setState({ + projects: [ + { + id: 'repo-1', + displayName: 'repo', + badgeColor: '#000000', + sourceRepoIds: ['repo-1'], + createdAt: 0, + updatedAt: 0, + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + } + ], + repos: [ + { + id: 'repo-1', + path: 'C:\\Users\\jinwo\\repo', + displayName: 'repo', + badgeColor: '#000000', + addedAt: 0 + } + ], + worktreesByRepo: { 'repo-1': [worktree] }, + activeRepoId: 'repo-1', + activeView: 'terminal', + tabsByWorktree: {}, + unifiedTabsByWorktree: {}, + groupsByWorktree: {}, + layoutByWorktree: {}, + activeGroupIdByWorktree: {}, + openFiles: [], + browserTabsByWorktree: {}, + activeFileIdByWorktree: {}, + activeBrowserTabIdByWorktree: {}, + activeTabTypeByWorktree: {}, + activeTabIdByWorktree: {}, + tabBarOrderByWorktree: {}, + pendingStartupByTabId: {}, + settings: { + agentCmdOverrides: {}, + agentDefaultArgs: { codex: '--profile "don\'t"' }, + setupScriptLaunchMode: 'new-tab' + } as unknown as ReturnType['settings'], + markWorktreeVisited: vi.fn(), + recordWorktreeVisit: vi.fn(), + refreshGitHubForWorktreeIfStale: vi.fn(), + revealWorktreeInSidebar: vi.fn() + }) + + const result = activateAndRevealWorktree(worktree.id) + const state = useAppStore.getState() + const reopenedTab = state.tabsByWorktree[worktree.id]?.[0] + + expect(result).toEqual({ primaryTabId: reopenedTab?.id }) + expect(state.pendingStartupByTabId[reopenedTab!.id]?.command).toContain("'don'\\''t'") + expect(state.pendingStartupByTabId[reopenedTab!.id]?.command).not.toContain("'don''t'") + }) + it('automatically resumes sleeping agent sessions when activating a slept worktree', () => { const worktree = makeWorktree() const revealWorktreeInSidebar = vi.fn() diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index f5187e59a3d..a82cb3a5e01 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -11,6 +11,7 @@ import type { EventProps } from '../../../shared/telemetry-events' import { shouldAutoCreateInitialTerminal } from '@/components/terminal/initial-terminal' import { buildSetupRunnerCommand } from './setup-runner' import { buildAgentStartupPlan } from './tui-agent-startup' +import { getAgentLaunchPlatformForRepo } from '@/lib/agent-launch-platform' import { CLIENT_PLATFORM } from './new-workspace' import { tuiAgentToAgentKind } from './telemetry' import { agentKindToTuiAgent } from '../../../shared/agent-kind' @@ -38,6 +39,7 @@ import { } from '../../../shared/tui-agent-launch-defaults' import { isTuiAgent } from '../../../shared/tui-agent-config' import { resumeSleepingAgentSessionsForWorktree } from '@/lib/resume-sleeping-agent-session' +import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { getRuntimeEnvironmentIdForWorktree, type WorktreeRuntimeOwnerState @@ -213,13 +215,22 @@ function buildCreatedAgentReopenStartup(worktree: Worktree): WorktreeStartupPayl return undefined } + const state = useAppStore.getState() + const repo = state.repos.find((entry) => entry.id === worktree.repoId) + const launchPlatform = repo + ? getAgentLaunchPlatformForRepo( + repo, + repo.connectionId ? undefined : getLocalProjectExecutionRuntimeContext(state, worktree.id) + ) + : CLIENT_PLATFORM + const startupPlan = buildAgentStartupPlan({ agent, prompt: '', - cmdOverrides: useAppStore.getState().settings?.agentCmdOverrides ?? {}, - agentArgs: resolveTuiAgentLaunchArgs(agent, useAppStore.getState().settings?.agentDefaultArgs), - agentEnv: resolveTuiAgentLaunchEnv(agent, useAppStore.getState().settings?.agentDefaultEnv), - platform: CLIENT_PLATFORM, + cmdOverrides: state.settings?.agentCmdOverrides ?? {}, + agentArgs: resolveTuiAgentLaunchArgs(agent, state.settings?.agentDefaultArgs), + agentEnv: resolveTuiAgentLaunchEnv(agent, state.settings?.agentDefaultEnv), + platform: launchPlatform, allowEmptyPromptLaunch: true }) if (!startupPlan) { diff --git a/src/renderer/src/store/slices/detected-agents.test.ts b/src/renderer/src/store/slices/detected-agents.test.ts index 8078ac331f8..6554ce392d4 100644 --- a/src/renderer/src/store/slices/detected-agents.test.ts +++ b/src/renderer/src/store/slices/detected-agents.test.ts @@ -27,6 +27,9 @@ globalThis.window = { }, runtimeEnvironments: { call: runtimeEnvironmentCall + }, + platform: { + get: () => ({ platform: 'win32' }) } } as unknown as Window['api'] } as Window & typeof globalThis @@ -119,7 +122,20 @@ describe('createDetectedAgentsSlice WSL context', () => { await expect(store.getState().ensureDetectedAgents()).resolves.toEqual(['claude']) - expect(detectAgents).toHaveBeenCalledWith({ wslDistro: 'Ubuntu' }) + expect(detectAgents).toHaveBeenCalledWith({ + wslDistro: 'Ubuntu', + projectRuntime: { + status: 'resolved', + runtime: { + kind: 'wsl', + hostPlatform: 'wsl', + projectId: 'repo-1', + distro: 'Ubuntu', + reason: 'project-override', + cacheKey: 'repo-1:wsl:Ubuntu' + } + } + }) }) it('refreshes local agents inside the active WSL repo distro when no worktree is selected', async () => { @@ -131,22 +147,59 @@ describe('createDetectedAgentsSlice WSL context', () => { await expect(store.getState().refreshDetectedAgents()).resolves.toEqual(['codex']) - expect(refreshAgents).toHaveBeenCalledWith({ wslDistro: 'Debian' }) + expect(refreshAgents).toHaveBeenCalledWith({ + wslDistro: 'Debian', + projectRuntime: { + status: 'resolved', + runtime: { + kind: 'wsl', + hostPlatform: 'wsl', + projectId: 'repo-1', + distro: 'Debian', + reason: 'project-override', + cacheKey: 'repo-1:wsl:Debian' + } + } + }) }) - it('detects local agents in the default WSL distro when the default Windows shell is WSL', async () => { + it('clears local agents when the project runtime requires repair before detection', async () => { + detectAgents.mockImplementation(async (context) => { + if (context?.projectRuntime?.status === 'repair-required') { + throw new Error('Project runtime requires repair before agent detection') + } + return ['claude'] + }) const store = createTestStore({ - settings: { - terminalWindowsShell: 'wsl.exe' - } as AppState['settings'], repos: [makeRepo({ id: 'repo-1', path: 'C:\\repo' })], activeRepoId: 'repo-1', activeWorktreeId: null }) await expect(store.getState().ensureDetectedAgents()).resolves.toEqual(['claude']) + expect(store.getState().detectedAgentIds).toEqual(['claude']) - expect(detectAgents).toHaveBeenCalledWith({ wslDefault: true }) + store.setState({ + settings: { + terminalWindowsShell: 'wsl.exe' + } as AppState['settings'] + } as Partial) + + await expect(store.getState().ensureDetectedAgents()).resolves.toEqual([]) + expect(store.getState().detectedAgentIds).toEqual([]) + + expect(detectAgents).toHaveBeenCalledWith({ + projectRuntime: { + status: 'repair-required', + repair: { + projectId: 'repo-1', + preferredRuntime: { kind: 'wsl', distro: null }, + reason: 'wsl-distro-required', + source: 'global-default', + cacheKey: 'repo-1:repair:wsl-distro-required:default' + } + } + }) }) it('detects local agents in the selected WSL distro when the default Windows shell is WSL', async () => { @@ -162,7 +215,20 @@ describe('createDetectedAgentsSlice WSL context', () => { await expect(store.getState().ensureDetectedAgents()).resolves.toEqual(['claude']) - expect(detectAgents).toHaveBeenCalledWith({ wslDistro: 'Debian' }) + expect(detectAgents).toHaveBeenCalledWith({ + wslDistro: 'Debian', + projectRuntime: { + status: 'resolved', + runtime: { + kind: 'wsl', + hostPlatform: 'wsl', + projectId: 'repo-1', + distro: 'Debian', + reason: 'global-default', + cacheKey: 'repo-1:wsl:Debian' + } + } + }) }) it('detects Windows agents when explicit agent location is Windows', async () => { @@ -179,7 +245,18 @@ describe('createDetectedAgentsSlice WSL context', () => { await expect(store.getState().ensureDetectedAgents()).resolves.toEqual(['claude']) - expect(detectAgents).toHaveBeenCalledWith(undefined) + expect(detectAgents).toHaveBeenCalledWith({ + projectRuntime: { + status: 'resolved', + runtime: { + kind: 'windows-host', + hostPlatform: 'win32', + projectId: 'repo-1', + reason: 'global-default', + cacheKey: 'repo-1:windows-host' + } + } + }) }) it('detects WSL agents when explicit agent location is WSL', async () => { @@ -196,7 +273,55 @@ describe('createDetectedAgentsSlice WSL context', () => { await expect(store.getState().ensureDetectedAgents()).resolves.toEqual(['claude']) - expect(detectAgents).toHaveBeenCalledWith({ wslDistro: 'Fedora' }) + expect(detectAgents).toHaveBeenCalledWith({ + wslDistro: 'Fedora', + projectRuntime: { + status: 'resolved', + runtime: { + kind: 'wsl', + hostPlatform: 'wsl', + projectId: 'repo-1', + distro: 'Fedora', + reason: 'global-default', + cacheKey: 'repo-1:wsl:Fedora' + } + } + }) + }) + + it('detects agents in the project override runtime instead of legacy agent location', async () => { + const store = createTestStore({ + settings: { + localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' }, + localAgentRuntime: 'wsl', + localAgentWslDistro: 'Ubuntu' + } as AppState['settings'], + projects: [ + { + id: 'repo-1', + sourceRepoIds: ['repo-1'], + localWindowsRuntimePreference: { kind: 'windows-host' } + } + ], + repos: [makeRepo({ id: 'repo-1', path: 'C:\\repo' })], + activeRepoId: 'repo-1', + activeWorktreeId: null + } as Partial) + + await expect(store.getState().ensureDetectedAgents()).resolves.toEqual(['claude']) + + expect(detectAgents).toHaveBeenCalledWith({ + projectRuntime: { + status: 'resolved', + runtime: { + kind: 'windows-host', + hostPlatform: 'win32', + projectId: 'repo-1', + reason: 'project-override', + cacheKey: 'repo-1:windows-host' + } + } + }) }) it('does not keep previous context agents when detection fails after a context switch', async () => { @@ -224,6 +349,45 @@ describe('createDetectedAgentsSlice WSL context', () => { await expect(detected).resolves.toEqual([]) expect(store.getState().detectedAgentIds).toEqual([]) }) + + it('clears local detection cache explicitly after a project runtime switch', async () => { + const store = createTestStore({ + repos: [makeRepo({ id: 'repo-1', path: 'C:\\repo' })], + activeRepoId: 'repo-1', + activeWorktreeId: null + }) + + await expect(store.getState().ensureDetectedAgents()).resolves.toEqual(['claude']) + expect(store.getState().detectedAgentIds).toEqual(['claude']) + + store.getState().clearLocalDetectedAgents() + + expect(store.getState().detectedAgentIds).toBeNull() + await expect(store.getState().ensureDetectedAgents()).resolves.toEqual(['claude']) + expect(detectAgents).toHaveBeenCalledTimes(2) + }) + + it('ignores in-flight local detection results after a project runtime switch', async () => { + let resolveDetection: (agents: string[]) => void = () => {} + detectAgents.mockReturnValueOnce( + new Promise((resolve) => { + resolveDetection = resolve + }) + ) + const store = createTestStore({ + repos: [makeRepo({ id: 'repo-1', path: 'C:\\repo' })], + activeRepoId: 'repo-1', + activeWorktreeId: null + }) + + const pending = store.getState().ensureDetectedAgents() + store.getState().clearLocalDetectedAgents() + resolveDetection(['claude']) + + await expect(pending).resolves.toEqual(['claude']) + expect(store.getState().detectedAgentIds).toBeNull() + expect(store.getState().isDetectingAgents).toBe(false) + }) }) describe('createDetectedAgentsSlice remote detection', () => { diff --git a/src/renderer/src/store/slices/detected-agents.ts b/src/renderer/src/store/slices/detected-agents.ts index 93bc9eb0e35..bf2017ccbc5 100644 --- a/src/renderer/src/store/slices/detected-agents.ts +++ b/src/renderer/src/store/slices/detected-agents.ts @@ -24,6 +24,7 @@ export type DetectedAgentsSlice = { * receive the same pending promise; store fields update once on resolve so * every subscribed surface re-renders in the same tick. */ refreshDetectedAgents: () => Promise + clearLocalDetectedAgents: () => void // Why: remote worktrees need per-connection agent detection. The local // detectedAgentIds field is connection-unaware, so remote state lives in a @@ -46,6 +47,7 @@ export type DetectedAgentsSlice = { let detectPromise: { key: string; promise: Promise } | null = null let refreshPromise: { key: string; promise: Promise } | null = null let detectedContextKey: string | null = null +let localDetectionGeneration = 0 const remoteDetectPromises = new Map>() const runtimeDetectPromises = new Map>() @@ -82,22 +84,27 @@ export const createDetectedAgentsSlice: StateCreator { const typed = ids as TuiAgent[] - set({ detectedAgentIds: typed, isDetectingAgents: false }) - detectedContextKey = contextKey + if (requestGeneration === localDetectionGeneration) { + set({ detectedAgentIds: typed, isDetectingAgents: false }) + detectedContextKey = contextKey + } return typed }) .catch(() => { // Why: allow a retry on the next call if detection blew up (IPC timeout // during cold start). Do not cache the failure or show stale context. - detectPromise = null - set({ - detectedAgentIds: contextChanged ? [] : get().detectedAgentIds, - isDetectingAgents: false - }) + if (requestGeneration === localDetectionGeneration) { + detectPromise = null + set({ + detectedAgentIds: contextChanged ? [] : get().detectedAgentIds, + isDetectingAgents: false + }) + } return [] as TuiAgent[] }) detectPromise = { key: contextKey, promise: pending } @@ -115,28 +122,33 @@ export const createDetectedAgentsSlice: StateCreator { const typed = result.agents as TuiAgent[] - set({ - detectedAgentIds: typed, - isRefreshingAgents: false, - pathSource: result.pathSource, - pathFailureReason: result.pathFailureReason - }) - // Why: once refresh has run, treat its result as the current detection - // snapshot so `ensureDetectedAgents` short-circuits. - detectedContextKey = contextKey - detectPromise = { key: contextKey, promise: Promise.resolve(typed) } + if (requestGeneration === localDetectionGeneration) { + set({ + detectedAgentIds: typed, + isRefreshingAgents: false, + pathSource: result.pathSource, + pathFailureReason: result.pathFailureReason + }) + // Why: once refresh has run, treat its result as the current detection + // snapshot so `ensureDetectedAgents` short-circuits. + detectedContextKey = contextKey + detectPromise = { key: contextKey, promise: Promise.resolve(typed) } + } return typed }) .catch(() => { const fallback = contextChanged ? [] : (get().detectedAgentIds ?? []) - set({ - detectedAgentIds: fallback, - isRefreshingAgents: false - }) + if (requestGeneration === localDetectionGeneration) { + set({ + detectedAgentIds: fallback, + isRefreshingAgents: false + }) + } return fallback }) .finally(() => { @@ -148,6 +160,20 @@ export const createDetectedAgentsSlice: StateCreator { + localDetectionGeneration += 1 + detectPromise = null + refreshPromise = null + detectedContextKey = null + set({ + detectedAgentIds: null, + isDetectingAgents: false, + isRefreshingAgents: false, + pathSource: null, + pathFailureReason: null + }) + }, + remoteDetectedAgentIds: {}, isDetectingRemoteAgents: {}, runtimeDetectedAgentIds: {}, diff --git a/src/renderer/src/store/slices/preflight.test.ts b/src/renderer/src/store/slices/preflight.test.ts index 571386127e7..d2ec08ce45d 100644 --- a/src/renderer/src/store/slices/preflight.test.ts +++ b/src/renderer/src/store/slices/preflight.test.ts @@ -7,6 +7,7 @@ import { createPreflightSlice } from './preflight' const preflightCheck = vi.fn() const callRuntimeRpc = vi.fn() +const platformGet = vi.fn(() => ({ platform: 'linux' })) vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: (...args: unknown[]) => callRuntimeRpc(...args), @@ -31,6 +32,9 @@ globalThis.window = { pathFailureReason: 'spawn_error' }), detectRemoteAgents: vi.fn().mockResolvedValue([]) + }, + platform: { + get: platformGet } } as unknown as Window['api'] } as Window & typeof globalThis @@ -47,6 +51,7 @@ function createTestStore() { function resetPreflightMocks(): void { preflightCheck.mockReset() callRuntimeRpc.mockReset() + platformGet.mockReset().mockReturnValue({ platform: 'linux' }) } function makeStatus(glabInstalled: boolean): PreflightStatus { @@ -185,6 +190,85 @@ describe('createPreflightSlice', () => { expect(preflightCheck).toHaveBeenCalledWith({ wslDistro: 'Ubuntu' }) }) + it('checks integrations through the resolved project runtime on Windows', async () => { + resetPreflightMocks() + platformGet.mockReturnValue({ platform: 'win32' }) + preflightCheck.mockResolvedValueOnce(makeStatus(true)) + const store = createTestStore() + store.setState({ + repos: [ + makeRepo({ + id: 'repo-1', + path: 'C:\\repo' + }) + ], + worktreesByRepo: { + 'repo-1': [ + makeWorktree({ + id: 'wt-1', + repoId: 'repo-1', + path: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo' + }) + ] + }, + activeRepoId: 'repo-1', + activeWorktreeId: 'wt-1' + } as Partial) + + await store.getState().refreshPreflightStatus() + + expect(preflightCheck).toHaveBeenCalledWith({ + projectRuntime: { + status: 'resolved', + runtime: { + kind: 'wsl', + hostPlatform: 'wsl', + projectId: 'repo-1', + distro: 'Ubuntu', + reason: 'project-override', + cacheKey: 'repo-1:wsl:Ubuntu' + } + }, + wslDistro: 'Ubuntu' + }) + }) + + it('passes repair-required project runtime context through Windows preflight errors', async () => { + resetPreflightMocks() + platformGet.mockReturnValue({ platform: 'win32' }) + preflightCheck.mockRejectedValueOnce( + new Error('Project runtime requires repair before preflight: wsl-distro-required') + ) + const store = createTestStore() + store.setState({ + settings: { + localWindowsRuntimeDefault: { kind: 'wsl', distro: null } + }, + repos: [makeRepo({ id: 'repo-1', path: 'C:\\repo' })], + worktreesByRepo: {}, + activeRepoId: 'repo-1', + activeWorktreeId: null + } as Partial) + + await store.getState().refreshPreflightStatus() + + expect(preflightCheck).toHaveBeenCalledWith({ + projectRuntime: { + status: 'repair-required', + repair: { + projectId: 'repo-1', + preferredRuntime: { kind: 'wsl', distro: null }, + reason: 'wsl-distro-required', + source: 'global-default', + cacheKey: 'repo-1:repair:wsl-distro-required:default' + } + } + }) + expect(store.getState().preflightStatusError).toBe( + 'Project runtime requires repair before preflight: wsl-distro-required' + ) + }) + it('keeps preflight request dedupe scoped by WSL distro context', async () => { resetPreflightMocks() const ubuntu = deferred() diff --git a/src/renderer/src/store/slices/preflight.ts b/src/renderer/src/store/slices/preflight.ts index 162c91083b9..05e60383749 100644 --- a/src/renderer/src/store/slices/preflight.ts +++ b/src/renderer/src/store/slices/preflight.ts @@ -1,5 +1,5 @@ import type { StateCreator } from 'zustand' -import type { PreflightStatus } from '../../../../preload/api-types' +import type { PreflightRuntimeContext, PreflightStatus } from '../../../../preload/api-types' import type { AppState } from '../types' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { @@ -29,14 +29,16 @@ function getErrorMessage(error: unknown): string { function buildPreflightArgs( force: boolean, context: LocalPreflightContext -): { force?: boolean; wslDistro?: string | null; wslDefault?: boolean } | undefined { +): (PreflightRuntimeContext & { force?: boolean }) | undefined { const wslDistro = context?.wslDistro const wslDefault = context?.wslDefault === true - if (!force && !wslDistro && !wslDefault) { + const projectRuntime = context?.projectRuntime + if (!force && !wslDistro && !wslDefault && !projectRuntime) { return undefined } return { ...(force ? { force: true } : {}), + ...(projectRuntime ? { projectRuntime } : {}), ...(wslDistro ? { wslDistro } : {}), ...(wslDefault ? { wslDefault: true } : {}) } diff --git a/src/renderer/src/store/slices/repos-project-runtime.test.ts b/src/renderer/src/store/slices/repos-project-runtime.test.ts new file mode 100644 index 00000000000..d3fefb1d8c4 --- /dev/null +++ b/src/renderer/src/store/slices/repos-project-runtime.test.ts @@ -0,0 +1,227 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestStore } from './store-test-helpers' +import type { Project, ProjectHostSetup, Repo } from '../../../../shared/types' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '../../runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' + +const localRepo: Repo = { + id: 'local-repo', + path: '/local', + displayName: 'Local', + badgeColor: '#000', + addedAt: 1 +} + +const projectsList = vi.fn() +const projectsUpdate = vi.fn() +const projectsCreateHostSetup = vi.fn() +const projectsSetupExistingFolder = vi.fn() +const projectsUpdateHostSetup = vi.fn() +const projectsDeleteHostSetup = vi.fn() +const reposList = vi.fn() +const runtimeEnvironmentCall = vi.fn() +const runtimeEnvironmentTransportCall = vi.fn() +const dispatchEventMock = vi.fn() + +beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + projectsList.mockReset() + projectsUpdate.mockReset() + projectsCreateHostSetup.mockReset() + projectsSetupExistingFolder.mockReset() + projectsUpdateHostSetup.mockReset() + projectsDeleteHostSetup.mockReset() + reposList.mockReset() + runtimeEnvironmentCall.mockReset() + runtimeEnvironmentTransportCall.mockReset() + dispatchEventMock.mockReset() + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + }) + vi.stubGlobal('window', { + api: { + repos: { + list: reposList + }, + projects: { + list: projectsList, + update: projectsUpdate, + listHostSetups: vi.fn(), + createHostSetup: projectsCreateHostSetup, + setupExistingFolder: projectsSetupExistingFolder, + updateHostSetup: projectsUpdateHostSetup, + deleteHostSetup: projectsDeleteHostSetup + }, + runtimeEnvironments: { call: runtimeEnvironmentTransportCall } + }, + dispatchEvent: dispatchEventMock + }) +}) + +function expectInstalledSkillRefreshEvent(): void { + expect( + dispatchEventMock.mock.calls.some(([event]) => { + return event instanceof CustomEvent && event.type === 'orca:installed-agent-skills-changed' + }) + ).toBe(true) +} + +describe('repo slice project runtime updates', () => { + it('clears local runtime-scoped detection state when project runtime changes', async () => { + const project: Project = { + id: 'project-1', + displayName: 'Project', + badgeColor: '#000', + sourceRepoIds: ['local-repo'], + createdAt: 1, + updatedAt: 1 + } + projectsUpdate.mockResolvedValue({ + ...project, + localWindowsRuntimePreference: { kind: 'windows-host' } + }) + const store = createTestStore() + store.setState({ + projects: [project], + detectedAgentIds: ['claude'], + isDetectingAgents: true, + isRefreshingAgents: true + }) + + await store.getState().updateProject(project.id, { + localWindowsRuntimePreference: { kind: 'windows-host' } + }) + + expect(store.getState().detectedAgentIds).toBeNull() + expect(store.getState().isDetectingAgents).toBe(false) + expect(store.getState().isRefreshingAgents).toBe(false) + expectInstalledSkillRefreshEvent() + }) + + it('hydrates projects from local IPC when the project API is available', async () => { + const project: Project = { + id: 'project-1', + displayName: 'Project', + badgeColor: '#000', + sourceRepoIds: ['local-repo'], + createdAt: 1, + updatedAt: 1 + } + const setup: ProjectHostSetup = { + id: 'setup-1', + projectId: project.id, + hostId: 'local', + repoId: 'local-repo', + path: '/local', + displayName: 'Local', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1 + } + projectsList.mockResolvedValue([project]) + window.api.projects.listHostSetups = vi.fn().mockResolvedValue([setup]) + reposList.mockResolvedValue([localRepo]) + const store = createTestStore() + + await store.getState().fetchRepos() + + expect(store.getState().projects).toEqual([project]) + expect(store.getState().projectHostSetups).toEqual([setup]) + expect(projectsList).toHaveBeenCalled() + expect(window.api.projects.listHostSetups).toHaveBeenCalled() + }) + + it('updates local project runtime preferences through the projects API', async () => { + const project: Project = { + id: 'project-1', + displayName: 'Project', + badgeColor: '#000', + sourceRepoIds: ['local-repo'], + createdAt: 1, + updatedAt: 1 + } + projectsUpdate.mockResolvedValue({ + ...project, + localWindowsRuntimePreference: { kind: 'windows-host' } + }) + const store = createTestStore() + store.setState({ projects: [project] }) + + await store.getState().updateProject(project.id, { + localWindowsRuntimePreference: { kind: 'windows-host' } + }) + + expect(store.getState().projects[0]?.localWindowsRuntimePreference).toEqual({ + kind: 'windows-host' + }) + expect(projectsUpdate).toHaveBeenCalledWith({ + projectId: project.id, + updates: { localWindowsRuntimePreference: { kind: 'windows-host' } } + }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('updates remote project runtime preferences through the active runtime', async () => { + const project: Project = { + id: 'project-1', + displayName: 'Project', + badgeColor: '#000', + sourceRepoIds: ['remote-repo'], + createdAt: 1, + updatedAt: 1 + } + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-project-update', + ok: true, + result: { + project: { + ...project, + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + } + }, + _meta: { runtimeId: 'runtime-remote' } + }) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + projects: [project], + projectHostSetups: [ + { + id: 'setup-1', + projectId: project.id, + hostId: 'runtime:env-1', + repoId: 'remote-repo', + path: '/srv/repo', + displayName: 'Remote', + setupState: 'ready', + setupMethod: 'imported-existing-folder', + createdAt: 1, + updatedAt: 1 + } + ] + }) + + await store.getState().updateProject(project.id, { + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + }) + + expect(store.getState().projects[0]?.localWindowsRuntimePreference).toEqual({ + kind: 'wsl', + distro: 'Ubuntu' + }) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'project.update', + params: { + projectId: project.id, + updates: { localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } } + }, + timeoutMs: 15_000 + }) + expect(projectsUpdate).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/store/slices/repos.test.ts b/src/renderer/src/store/slices/repos.test.ts index 00adf6dae75..aa88907062b 100644 --- a/src/renderer/src/store/slices/repos.test.ts +++ b/src/renderer/src/store/slices/repos.test.ts @@ -45,6 +45,7 @@ const projectsCreateHostSetup = vi.fn() const projectsSetupExistingFolder = vi.fn() const projectsUpdateHostSetup = vi.fn() const projectsDeleteHostSetup = vi.fn() +const projectsUpdate = vi.fn() const projectGroupsMoveProject = vi.fn() const ptyKill = vi.fn() const runtimeEnvironmentCall = vi.fn() @@ -64,6 +65,7 @@ beforeEach(() => { projectsSetupExistingFolder.mockReset() projectsUpdateHostSetup.mockReset() projectsDeleteHostSetup.mockReset() + projectsUpdate.mockReset() projectGroupsMoveProject.mockReset() ptyKill.mockReset() runtimeEnvironmentCall.mockReset() @@ -84,6 +86,7 @@ beforeEach(() => { reorder: reposReorder }, projects: { + update: projectsUpdate, createHostSetup: projectsCreateHostSetup, setupExistingFolder: projectsSetupExistingFolder, updateHostSetup: projectsUpdateHostSetup, @@ -116,59 +119,6 @@ describe('repo slice runtime routing', () => { expect(runtimeEnvironmentCall).not.toHaveBeenCalled() }) - it('hydrates projects from local IPC when the project API is available', async () => { - const project: Project = { - id: 'project-1', - displayName: 'Project', - badgeColor: '#000', - sourceRepoIds: ['local-repo'], - createdAt: 1, - updatedAt: 1 - } - const setup: ProjectHostSetup = { - id: 'setup-1', - projectId: project.id, - hostId: 'local', - repoId: 'local-repo', - path: '/local', - displayName: 'Local', - setupState: 'ready', - setupMethod: 'legacy-repo', - createdAt: 1, - updatedAt: 1 - } - const projectsList = vi.fn().mockResolvedValue([project]) - const listHostSetups = vi.fn().mockResolvedValue([setup]) - ;( - window.api as typeof window.api & { - projects?: { - list: typeof projectsList - listHostSetups: typeof listHostSetups - createHostSetup: typeof projectsCreateHostSetup - setupExistingFolder: typeof projectsSetupExistingFolder - updateHostSetup: typeof projectsUpdateHostSetup - deleteHostSetup: typeof projectsDeleteHostSetup - } - } - ).projects = { - list: projectsList, - listHostSetups, - createHostSetup: projectsCreateHostSetup, - setupExistingFolder: projectsSetupExistingFolder, - updateHostSetup: projectsUpdateHostSetup, - deleteHostSetup: projectsDeleteHostSetup - } - reposList.mockResolvedValue([localRepo]) - const store = createTestStore() - - await store.getState().fetchRepos() - - expect(store.getState().projects).toEqual([project]) - expect(store.getState().projectHostSetups).toEqual([setup]) - expect(projectsList).toHaveBeenCalled() - expect(listHostSetups).toHaveBeenCalled() - }) - it('fetches repos from the active remote runtime environment', async () => { runtimeEnvironmentCall.mockResolvedValue({ id: 'rpc-1', diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index b8a502d7592..bcecaefca5d 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -8,6 +8,7 @@ import type { AppState } from '../types' import type { GlobalSettings, Project, + ProjectUpdateArgs, Repo, ProjectGroup, ProjectHostSetup, @@ -57,6 +58,7 @@ import { buildDismissedOnboardingFolderAgentStartup } from '@/lib/onboarding-fol import { markOnboardingProjectAdded } from '@/lib/onboarding-project-checklist' import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' import { filterSetupScriptPromptDismissalsToValidRepos } from '@/lib/setup-script-prompt' +import { notifyInstalledAgentSkillsChanged } from '@/hooks/useInstalledAgentSkills' import { translate } from '@/i18n/i18n' import { getRepoExecutionHostId, @@ -93,6 +95,8 @@ type RepoUpdate = Partial< > > & { sourceControlAi?: Repo['sourceControlAi'] | null } +type ProjectUpdate = ProjectUpdateArgs['updates'] + type NestedRepoScanControls = { scanId?: string onProgress?: (scan: NestedRepoScanResult) => void @@ -211,6 +215,22 @@ function getProjectSetupRuntimeTarget( : { kind: 'local' } } +function getProjectUpdateRuntimeTarget( + state: AppState, + projectId: string +): ReturnType { + const target = getActiveRuntimeTarget(state.settings) + if (target.kind !== 'environment') { + return target + } + const runtimeHostId = getRuntimeTargetHostId(target) + return state.projectHostSetups.some( + (setup) => setup.projectId === projectId && setup.hostId === runtimeHostId + ) + ? target + : { kind: 'local' } +} + function getSafeAutoForkSyncKey(repo: Repo): string { return `${getRepoExecutionHostId(repo)}:${repo.id}:${repo.path}` } @@ -701,6 +721,7 @@ export type RepoSlice = { order?: number ) => Promise removeProject: (projectId: string) => Promise + updateProject: (projectId: string, updates: ProjectUpdate) => Promise updateRepo: (projectId: string, updates: RepoUpdate) => Promise setActiveRepo: (projectId: string | null) => void reorderRepos: (orderedIds: string[]) => Promise @@ -1746,6 +1767,41 @@ export const createRepoSlice: StateCreator = (set, } }, + updateProject: async (projectId, updates) => { + try { + const target = getProjectUpdateRuntimeTarget(get(), projectId) + const updatedProject = + target.kind === 'local' + ? await window.api.projects.update({ projectId, updates }) + : ( + await callRuntimeRpc<{ project: Project }>( + target, + 'project.update', + { projectId, updates }, + { timeoutMs: 15_000 } + ) + ).project + if (!updatedProject) { + return false + } + const runtimePreferenceChanged = 'localWindowsRuntimePreference' in updates + set((state) => ({ + projects: state.projects.map((project) => + project.id === projectId ? updatedProject : project + ), + folderWorkspacePathStatuses: {} + })) + if (runtimePreferenceChanged) { + get().clearLocalDetectedAgents() + notifyInstalledAgentSkillsChanged() + } + return true + } catch (err) { + console.error('Failed to update project:', err) + return false + } + }, + updateRepo: async (projectId, updates) => { const updateRepoChains = getRepoUpdateChains(get) const applyRepoUpdate = async () => { diff --git a/src/renderer/src/store/slices/store-cascades.test.ts b/src/renderer/src/store/slices/store-cascades.test.ts index f4878d0e8fe..5a53d393ced 100644 --- a/src/renderer/src/store/slices/store-cascades.test.ts +++ b/src/renderer/src/store/slices/store-cascades.test.ts @@ -1162,19 +1162,113 @@ describe('setActiveWorktree', () => { const wt = 'repo1::/path/wt1' seedStore(store, { - settings: { ...getDefaultSettings('/tmp'), terminalWindowsShell: 'wsl.exe' }, + settings: { ...getDefaultSettings('/tmp'), terminalWindowsShell: 'cmd.exe' }, worktreesByRepo: { repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })] } }) const terminal = store.getState().createTab(wt) - expect(terminal.shellOverride).toBe('wsl.exe') + expect(terminal.shellOverride).toBe('cmd.exe') store.setState({ - settings: { ...store.getState().settings!, terminalWindowsShell: 'cmd.exe' } + settings: { ...store.getState().settings!, terminalWindowsShell: 'powershell.exe' } }) - expect(store.getState().tabsByWorktree[wt][0].shellOverride).toBe('wsl.exe') + expect(store.getState().tabsByWorktree[wt][0].shellOverride).toBe('cmd.exe') + } finally { + Object.defineProperty(globalThis, 'navigator', { + value: originalNavigator, + configurable: true + }) + } + }) + + it('stamps host shell metadata when project runtime overrides stale WSL defaults', () => { + const originalNavigator = globalThis.navigator + Object.defineProperty(globalThis, 'navigator', { + value: { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' }, + configurable: true + }) + try { + const store = createTestStore() + const wt = 'repo1::C:\\repo' + + seedStore(store, { + settings: { + ...getDefaultSettings('/tmp'), + terminalWindowsShell: 'wsl.exe', + terminalWindowsWslDistro: 'Debian' + }, + projects: [ + { + id: 'project-1', + displayName: 'Project', + badgeColor: '#000', + sourceRepoIds: ['repo1'], + localWindowsRuntimePreference: { kind: 'windows-host' }, + createdAt: 0, + updatedAt: 0 + } + ], + worktreesByRepo: { + repo1: [ + makeWorktree({ + id: wt, + repoId: 'repo1', + projectId: 'project-1', + path: 'C:\\repo' + }) + ] + } + }) + + const terminal = store.getState().createTab(wt, undefined, 'wsl.exe') + expect(terminal.shellOverride).toBe('powershell.exe') + } finally { + Object.defineProperty(globalThis, 'navigator', { + value: originalNavigator, + configurable: true + }) + } + }) + + it('stamps WSL shell metadata when project runtime overrides host defaults', () => { + const originalNavigator = globalThis.navigator + Object.defineProperty(globalThis, 'navigator', { + value: { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' }, + configurable: true + }) + try { + const store = createTestStore() + const wt = 'repo1::C:\\repo' + + seedStore(store, { + settings: { ...getDefaultSettings('/tmp'), terminalWindowsShell: 'powershell.exe' }, + projects: [ + { + id: 'project-1', + displayName: 'Project', + badgeColor: '#000', + sourceRepoIds: ['repo1'], + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }, + createdAt: 0, + updatedAt: 0 + } + ], + worktreesByRepo: { + repo1: [ + makeWorktree({ + id: wt, + repoId: 'repo1', + projectId: 'project-1', + path: 'C:\\repo' + }) + ] + } + }) + + const terminal = store.getState().createTab(wt, undefined, 'cmd.exe') + expect(terminal.shellOverride).toBe('wsl.exe') } finally { Object.defineProperty(globalThis, 'navigator', { value: originalNavigator, diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 436ad5d7247..d2581a6c3ab 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -22,6 +22,8 @@ import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../../shared/stab import { isValidHostTerminalTabId, isValidTerminalTabId } from '../../../../shared/terminal-tab-id' import { getRepoIdFromWorktreeId, splitWorktreeId } from '../../../../shared/worktree-id' import { isWslUncPath } from '../../../../shared/wsl-paths' +import type { ProjectExecutionRuntimeResolution } from '../../../../shared/project-execution-runtime' +import { resolveLocalWindowsTerminalShellOverrideForTab } from '../../../../shared/local-windows-terminal-runtime' import type { AgentStartedTelemetry } from '../../lib/worktree-activation' import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' import { clearTransientTerminalState, emptyLayoutSnapshot } from './terminal-helpers' @@ -50,6 +52,7 @@ import { hasWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' import { sanitizeTerminalLayoutPaneTitles } from '@/lib/terminal-pane-title-sanitization' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { collectSleepingAgentSessionRecordsForWorktree } from './agent-status' function getNextTerminalOrdinal(tabs: TerminalTab[]): number { @@ -180,20 +183,23 @@ function resolveCreatedTabShellOverride( explicitShellOverride: string | undefined, defaultWindowsShell: string | undefined, isRemoteWorktree: boolean, - isWslWorktree: boolean + isWslWorktree: boolean, + projectRuntime: ProjectExecutionRuntimeResolution | undefined ): string | undefined { if (isRemoteWorktree) { return undefined } + if (isWindowsRendererRuntime()) { + return resolveLocalWindowsTerminalShellOverrideForTab({ + explicitShellOverride, + defaultWindowsShell, + isWslWorktree, + projectRuntime + }) + } if (explicitShellOverride !== undefined) { return explicitShellOverride } - if (isWindowsRendererRuntime()) { - if (isWslWorktree) { - return 'wsl.exe' - } - return defaultWindowsShell - } return undefined } @@ -667,16 +673,19 @@ export const createTerminalSlice: StateCreator const nextOrdinal = getNextTerminalOrdinal(existing) const defaultTitle = `Terminal ${nextOrdinal}` const quickCommandLabel = options?.quickCommandLabel?.trim() + const isRemoteWorktree = worktreeUsesRemoteConnection(s, worktreeId) + const isWslWorktree = worktreeUsesWslPath(s, worktreeId) const createdShellOverride = resolveCreatedTabShellOverride( shellOverride, s.settings?.terminalWindowsShell, // Why: SSH PTYs ignore local Windows shell selection; persisting a // local shell icon would mislabel a remote terminal. - worktreeUsesRemoteConnection(s, worktreeId), + isRemoteWorktree, // Why: WSL UNC worktrees are repo-scoped WSL environments. New default // terminals should enter that distro even when the global Windows shell // preference is PowerShell or cmd.exe. - worktreeUsesWslPath(s, worktreeId) + isWslWorktree, + isRemoteWorktree ? undefined : getLocalProjectExecutionRuntimeContext(s, worktreeId) ) tab = { id, diff --git a/src/shared/constants.test.ts b/src/shared/constants.test.ts index b074070c9c3..6c33a358ff8 100644 --- a/src/shared/constants.test.ts +++ b/src/shared/constants.test.ts @@ -65,6 +65,12 @@ describe('getDefaultSettings', () => { expect(getDefaultSettings('/tmp').compactWorktreeCards).toBe(false) }) + it('defaults local Windows projects to the host runtime', () => { + expect(getDefaultSettings('/tmp').localWindowsRuntimeDefault).toEqual({ + kind: 'windows-host' + }) + }) + it('suppresses notifications for the focused worktree by default for new users', () => { expect(getDefaultNotificationSettings().suppressWhenFocused).toBe(true) expect(getDefaultSettings('/tmp').notifications.suppressWhenFocused).toBe(true) diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 9023e50343c..aa214f714ab 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -237,6 +237,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings { terminalWindowsWslDistro: null, localAccountRuntime: 'host', localAccountWslDistro: null, + localWindowsRuntimeDefault: { kind: 'windows-host' }, // Why: Windows users expect "PowerShell" to mean modern PowerShell when it // is installed, with a safe fallback to the inbox Windows PowerShell. terminalWindowsPowerShellImplementation: 'auto', diff --git a/src/shared/local-windows-terminal-runtime.ts b/src/shared/local-windows-terminal-runtime.ts new file mode 100644 index 00000000000..0eb61d8bf08 --- /dev/null +++ b/src/shared/local-windows-terminal-runtime.ts @@ -0,0 +1,101 @@ +import type { ProjectExecutionRuntimeResolution } from './project-execution-runtime' +import type { GlobalSettings } from './types' + +type LocalWindowsTerminalRuntimeSettings = + | Partial> + | undefined + +export type LocalWindowsTerminalRuntimeOptions = { + shellOverride: string | undefined + terminalWindowsWslDistro: string | null +} + +export function isWslShellName(shellPath: string | undefined): boolean { + const shellName = shellPath?.replaceAll('\\', '/').split('/').pop()?.toLowerCase() + return shellName === 'wsl.exe' || shellName === 'wsl' +} + +export function getHostShellForProjectRuntime( + requestedShell: string | undefined, + settingsShell: string | undefined, + fallbackHostShell = 'powershell.exe' +): string { + const candidate = requestedShell ?? settingsShell + if (candidate && !isWslShellName(candidate)) { + return candidate + } + return fallbackHostShell +} + +export function resolveLocalWindowsTerminalRuntimeOptions(args: { + requestedShellOverride: string | undefined + settings: LocalWindowsTerminalRuntimeSettings + projectRuntime: ProjectExecutionRuntimeResolution | undefined + fallbackHostShell?: string +}): LocalWindowsTerminalRuntimeOptions { + const settingsShell = args.settings?.terminalWindowsShell + const settingsWslDistro = args.settings?.terminalWindowsWslDistro ?? null + const projectRuntime = args.projectRuntime + if (!projectRuntime) { + return { + shellOverride: args.requestedShellOverride ?? settingsShell, + terminalWindowsWslDistro: settingsWslDistro + } + } + + if (projectRuntime.status === 'repair-required') { + throw new Error( + `Project runtime requires repair before terminal spawn: ${projectRuntime.repair.reason}` + ) + } + + if (projectRuntime.runtime.kind === 'wsl') { + return { + shellOverride: 'wsl.exe', + terminalWindowsWslDistro: projectRuntime.runtime.distro + } + } + + return { + shellOverride: getHostShellForProjectRuntime( + args.requestedShellOverride, + settingsShell, + args.fallbackHostShell + ), + terminalWindowsWslDistro: null + } +} + +export function resolveLocalWindowsTerminalShellOverrideForTab(args: { + explicitShellOverride: string | undefined + defaultWindowsShell: string | undefined + isWslWorktree: boolean + projectRuntime: ProjectExecutionRuntimeResolution | undefined + fallbackHostShell?: string +}): string | undefined { + if (args.projectRuntime?.status === 'repair-required') { + // Why: repair-required WSL still owns the project runtime; the tab should + // advertise the intended runtime instead of falling back to host metadata. + return 'wsl.exe' + } + + if (args.projectRuntime) { + return resolveLocalWindowsTerminalRuntimeOptions({ + requestedShellOverride: args.explicitShellOverride, + settings: { + terminalWindowsShell: args.defaultWindowsShell, + terminalWindowsWslDistro: null + }, + projectRuntime: args.projectRuntime, + fallbackHostShell: args.fallbackHostShell + }).shellOverride + } + + if (args.explicitShellOverride !== undefined) { + return args.explicitShellOverride + } + if (args.isWslWorktree) { + return 'wsl.exe' + } + return args.defaultWindowsShell +} diff --git a/src/shared/project-execution-runtime.test.ts b/src/shared/project-execution-runtime.test.ts new file mode 100644 index 00000000000..ca8dc795bd6 --- /dev/null +++ b/src/shared/project-execution-runtime.test.ts @@ -0,0 +1,332 @@ +import { describe, expect, it } from 'vitest' +import { + deriveGlobalWindowsRuntimeDefaultFromLegacySettings, + normalizeProjectRuntimePreference, + resolveProjectExecutionRuntime +} from './project-execution-runtime' + +describe('normalizeProjectRuntimePreference', () => { + it('preserves valid project runtime preferences', () => { + expect(normalizeProjectRuntimePreference({ kind: 'inherit-global' })).toEqual({ + kind: 'inherit-global' + }) + expect(normalizeProjectRuntimePreference({ kind: 'windows-host' })).toEqual({ + kind: 'windows-host' + }) + expect(normalizeProjectRuntimePreference({ kind: 'wsl', distro: 'Ubuntu-24.04' })).toEqual({ + kind: 'wsl', + distro: 'Ubuntu-24.04' + }) + }) + + it('falls back malformed project runtime preferences to inherit-global', () => { + expect(normalizeProjectRuntimePreference(null)).toEqual({ kind: 'inherit-global' }) + expect(normalizeProjectRuntimePreference({ kind: 'wsl', distro: ' ' })).toEqual({ + kind: 'inherit-global' + }) + expect(normalizeProjectRuntimePreference({ kind: 'bogus', distro: 'Ubuntu' })).toEqual({ + kind: 'inherit-global' + }) + }) +}) + +describe('deriveGlobalWindowsRuntimeDefaultFromLegacySettings', () => { + it('defaults malformed legacy settings to the host global default', () => { + expect(deriveGlobalWindowsRuntimeDefaultFromLegacySettings(null)).toEqual({ + defaultRuntime: { kind: 'windows-host' }, + fallbackReason: null + }) + }) + + it('migrates existing host settings to the host global default', () => { + expect( + deriveGlobalWindowsRuntimeDefaultFromLegacySettings({ + localAgentRuntime: 'host', + terminalWindowsShell: 'wsl.exe', + terminalWindowsWslDistro: 'Ubuntu' + }) + ).toEqual({ + defaultRuntime: { kind: 'windows-host' }, + fallbackReason: null + }) + }) + + it('migrates existing WSL agent settings with their selected distro', () => { + expect( + deriveGlobalWindowsRuntimeDefaultFromLegacySettings({ + localAgentRuntime: 'wsl', + localAgentWslDistro: 'Ubuntu-24.04', + terminalWindowsWslDistro: 'Debian' + }) + ).toEqual({ + defaultRuntime: { kind: 'wsl', distro: 'Ubuntu-24.04' }, + fallbackReason: null + }) + }) + + it('uses the terminal WSL distro when the agent setting only selected WSL', () => { + expect( + deriveGlobalWindowsRuntimeDefaultFromLegacySettings({ + localAgentRuntime: 'wsl', + terminalWindowsWslDistro: 'Debian' + }) + ).toEqual({ + defaultRuntime: { kind: 'wsl', distro: 'Debian' }, + fallbackReason: null + }) + }) + + it('turns stale legacy WSL state into a migration host fallback when WSL is unavailable', () => { + expect( + deriveGlobalWindowsRuntimeDefaultFromLegacySettings( + { localAgentRuntime: 'wsl', localAgentWslDistro: 'Ubuntu' }, + { wslAvailable: false, availableWslDistros: [] } + ) + ).toEqual({ + defaultRuntime: { kind: 'windows-host' }, + fallbackReason: 'legacy-wsl-unavailable' + }) + }) + + it('turns stale legacy WSL distro state into a migration host fallback', () => { + expect( + deriveGlobalWindowsRuntimeDefaultFromLegacySettings( + { localAgentRuntime: 'wsl', localAgentWslDistro: 'Ubuntu' }, + { wslAvailable: true, availableWslDistros: ['Debian'] } + ) + ).toEqual({ + defaultRuntime: { kind: 'windows-host' }, + fallbackReason: 'legacy-wsl-distro-missing' + }) + }) +}) + +describe('resolveProjectExecutionRuntime', () => { + it('ignores local Windows WSL preferences on non-Windows platforms', () => { + expect( + resolveProjectExecutionRuntime({ + appPlatform: 'darwin', + projectId: 'project-1', + projectRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }, + globalWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' }, + wslAvailable: true, + availableWslDistros: ['Ubuntu'] + }) + ).toEqual({ + status: 'resolved', + runtime: { + kind: 'local-host', + hostPlatform: 'darwin', + projectId: 'project-1', + reason: 'non-windows', + cacheKey: 'project-1:local-host:darwin' + } + }) + }) + + it('resolves inherited Windows projects to the host global default', () => { + expect( + resolveProjectExecutionRuntime({ + appPlatform: 'win32', + projectId: 'project-1', + projectRuntimePreference: { kind: 'inherit-global' }, + globalWindowsRuntimeDefault: { kind: 'windows-host' }, + wslAvailable: true, + availableWslDistros: ['Ubuntu'] + }) + ).toEqual({ + status: 'resolved', + runtime: { + kind: 'windows-host', + hostPlatform: 'win32', + projectId: 'project-1', + reason: 'global-default', + cacheKey: 'project-1:windows-host' + } + }) + }) + + it('falls back malformed global defaults to Windows host', () => { + expect( + resolveProjectExecutionRuntime({ + appPlatform: 'win32', + projectId: 'project-1', + projectRuntimePreference: { kind: 'inherit-global' }, + globalWindowsRuntimeDefault: { kind: 'bogus', distro: 'Ubuntu' }, + wslAvailable: true, + availableWslDistros: ['Ubuntu'] + }) + ).toEqual({ + status: 'resolved', + runtime: { + kind: 'windows-host', + hostPlatform: 'win32', + projectId: 'project-1', + reason: 'global-default', + cacheKey: 'project-1:windows-host' + } + }) + }) + + it('resolves inherited Windows projects to the WSL global default', () => { + expect( + resolveProjectExecutionRuntime({ + appPlatform: 'win32', + projectId: 'project-1', + projectRuntimePreference: { kind: 'inherit-global' }, + globalWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' }, + wslAvailable: true, + availableWslDistros: ['Ubuntu', 'Debian'] + }) + ).toEqual({ + status: 'resolved', + runtime: { + kind: 'wsl', + hostPlatform: 'wsl', + projectId: 'project-1', + distro: 'Ubuntu', + reason: 'global-default', + cacheKey: 'project-1:wsl:Ubuntu' + } + }) + }) + + it('lets a project force host when the global default is WSL', () => { + expect( + resolveProjectExecutionRuntime({ + appPlatform: 'win32', + projectId: 'project-1', + projectRuntimePreference: { kind: 'windows-host' }, + globalWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' }, + wslAvailable: true, + availableWslDistros: ['Ubuntu'] + }) + ).toEqual({ + status: 'resolved', + runtime: { + kind: 'windows-host', + hostPlatform: 'win32', + projectId: 'project-1', + reason: 'project-override', + cacheKey: 'project-1:windows-host' + } + }) + }) + + it('lets a project force WSL when the global default is host', () => { + expect( + resolveProjectExecutionRuntime({ + appPlatform: 'win32', + projectId: 'project-1', + projectRuntimePreference: { kind: 'wsl', distro: 'Debian' }, + globalWindowsRuntimeDefault: { kind: 'windows-host' }, + wslAvailable: true, + availableWslDistros: ['Ubuntu', 'Debian'] + }) + ).toEqual({ + status: 'resolved', + runtime: { + kind: 'wsl', + hostPlatform: 'wsl', + projectId: 'project-1', + distro: 'Debian', + reason: 'project-override', + cacheKey: 'project-1:wsl:Debian' + } + }) + }) + + it('returns repair state instead of silently falling back when WSL is unavailable', () => { + expect( + resolveProjectExecutionRuntime({ + appPlatform: 'win32', + projectId: 'project-1', + projectRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }, + globalWindowsRuntimeDefault: { kind: 'windows-host' }, + wslAvailable: false, + availableWslDistros: [] + }) + ).toEqual({ + status: 'repair-required', + repair: { + projectId: 'project-1', + preferredRuntime: { kind: 'wsl', distro: 'Ubuntu' }, + reason: 'wsl-unavailable', + source: 'project-override', + cacheKey: 'project-1:repair:wsl-unavailable:Ubuntu' + } + }) + }) + + it('returns repair state when WSL is selected without a distro', () => { + expect( + resolveProjectExecutionRuntime({ + appPlatform: 'win32', + projectId: 'project-1', + projectRuntimePreference: { kind: 'inherit-global' }, + globalWindowsRuntimeDefault: { kind: 'wsl', distro: null }, + wslAvailable: true, + availableWslDistros: ['Ubuntu'] + }) + ).toEqual({ + status: 'repair-required', + repair: { + projectId: 'project-1', + preferredRuntime: { kind: 'wsl', distro: null }, + reason: 'wsl-distro-required', + source: 'global-default', + cacheKey: 'project-1:repair:wsl-distro-required:default' + } + }) + }) + + it('keeps two projects with different runtime preferences isolated', () => { + const hostProject = resolveProjectExecutionRuntime({ + appPlatform: 'win32', + projectId: 'host-project', + projectRuntimePreference: { kind: 'windows-host' }, + globalWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' }, + wslAvailable: true, + availableWslDistros: ['Ubuntu'] + }) + const wslProject = resolveProjectExecutionRuntime({ + appPlatform: 'win32', + projectId: 'wsl-project', + projectRuntimePreference: { kind: 'inherit-global' }, + globalWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' }, + wslAvailable: true, + availableWslDistros: ['Ubuntu'] + }) + + expect(hostProject).toMatchObject({ + status: 'resolved', + runtime: { kind: 'windows-host', cacheKey: 'host-project:windows-host' } + }) + expect(wslProject).toMatchObject({ + status: 'resolved', + runtime: { kind: 'wsl', distro: 'Ubuntu', cacheKey: 'wsl-project:wsl:Ubuntu' } + }) + }) + + it('returns repair state when the selected distro is missing', () => { + expect( + resolveProjectExecutionRuntime({ + appPlatform: 'win32', + projectId: 'project-1', + projectRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }, + globalWindowsRuntimeDefault: { kind: 'windows-host' }, + wslAvailable: true, + availableWslDistros: ['Debian'] + }) + ).toEqual({ + status: 'repair-required', + repair: { + projectId: 'project-1', + preferredRuntime: { kind: 'wsl', distro: 'Ubuntu' }, + reason: 'wsl-distro-missing', + source: 'project-override', + cacheKey: 'project-1:repair:wsl-distro-missing:Ubuntu' + } + }) + }) +}) diff --git a/src/shared/project-execution-runtime.ts b/src/shared/project-execution-runtime.ts new file mode 100644 index 00000000000..6e80c1e8dbb --- /dev/null +++ b/src/shared/project-execution-runtime.ts @@ -0,0 +1,294 @@ +export type LocalWindowsRuntimePreference = + | { kind: 'inherit-global' } + | { kind: 'windows-host' } + | { kind: 'wsl'; distro: string } + +export type GlobalWindowsRuntimeDefault = + | { kind: 'windows-host' } + | { kind: 'wsl'; distro: string | null } + +export type ProjectExecutionRuntimeReason = + | 'project-override' + | 'global-default' + | 'migration-fallback' + | 'non-windows' + +export type ResolvedProjectExecutionRuntime = + | { + kind: 'local-host' + hostPlatform: string + projectId: string + reason: 'non-windows' + cacheKey: string + } + | { + kind: 'windows-host' + hostPlatform: 'win32' + projectId: string + reason: Exclude + cacheKey: string + } + | { + kind: 'wsl' + hostPlatform: 'wsl' + projectId: string + distro: string + reason: 'project-override' | 'global-default' + cacheKey: string + } + +export type ProjectExecutionRuntimeRepairReason = + | 'wsl-unavailable' + | 'wsl-distro-required' + | 'wsl-distro-missing' + +export type ProjectExecutionRuntimeRepair = { + projectId: string + preferredRuntime: { kind: 'wsl'; distro: string | null } + reason: ProjectExecutionRuntimeRepairReason + source: 'project-override' | 'global-default' + cacheKey: string +} + +export type ProjectExecutionRuntimeResolution = + | { status: 'resolved'; runtime: ResolvedProjectExecutionRuntime } + | { status: 'repair-required'; repair: ProjectExecutionRuntimeRepair } + +export type LegacyWindowsRuntimeSettings = { + localAgentRuntime?: unknown + localAgentWslDistro?: unknown + terminalWindowsShell?: unknown + terminalWindowsWslDistro?: unknown +} + +export type LegacyWindowsRuntimeMigrationContext = { + wslAvailable?: boolean + availableWslDistros?: readonly string[] | null +} + +export type LegacyWindowsRuntimeFallbackReason = + | 'legacy-wsl-unavailable' + | 'legacy-wsl-distro-missing' + +export type LegacyWindowsRuntimeDefaultMigration = { + defaultRuntime: GlobalWindowsRuntimeDefault + fallbackReason: LegacyWindowsRuntimeFallbackReason | null +} + +export type ResolveProjectExecutionRuntimeArgs = { + appPlatform: string + projectId: string + projectRuntimePreference?: unknown + globalWindowsRuntimeDefault?: unknown + wslAvailable?: boolean + availableWslDistros?: readonly string[] | null +} + +type RuntimeSource = 'project-override' | 'global-default' + +export function normalizeProjectRuntimePreference(value: unknown): LocalWindowsRuntimePreference { + if (!isRecord(value)) { + return { kind: 'inherit-global' } + } + + if (value.kind === 'inherit-global') { + return { kind: 'inherit-global' } + } + + if (value.kind === 'windows-host') { + return { kind: 'windows-host' } + } + + if (value.kind === 'wsl') { + const distro = normalizeDistro(value.distro) + return distro ? { kind: 'wsl', distro } : { kind: 'inherit-global' } + } + + return { kind: 'inherit-global' } +} + +export function normalizeGlobalWindowsRuntimeDefault(value: unknown): GlobalWindowsRuntimeDefault { + if (!isRecord(value)) { + return { kind: 'windows-host' } + } + + if (value.kind === 'wsl') { + return { kind: 'wsl', distro: normalizeDistro(value.distro) } + } + + return { kind: 'windows-host' } +} + +export function deriveGlobalWindowsRuntimeDefaultFromLegacySettings( + settings: LegacyWindowsRuntimeSettings | null | undefined, + context: LegacyWindowsRuntimeMigrationContext = {} +): LegacyWindowsRuntimeDefaultMigration { + const selectedRuntime = settings?.localAgentRuntime + if (selectedRuntime === 'host') { + return { defaultRuntime: { kind: 'windows-host' }, fallbackReason: null } + } + + if (selectedRuntime === 'wsl' || isWslShell(settings?.terminalWindowsShell)) { + const distro = + normalizeDistro(settings?.localAgentWslDistro) ?? + normalizeDistro(settings?.terminalWindowsWslDistro) + const fallbackReason = getLegacyWslFallbackReason(distro, context) + if (fallbackReason) { + return { defaultRuntime: { kind: 'windows-host' }, fallbackReason } + } + return { defaultRuntime: { kind: 'wsl', distro }, fallbackReason: null } + } + + return { defaultRuntime: { kind: 'windows-host' }, fallbackReason: null } +} + +export function resolveProjectExecutionRuntime( + args: ResolveProjectExecutionRuntimeArgs +): ProjectExecutionRuntimeResolution { + if (args.appPlatform !== 'win32') { + return { + status: 'resolved', + runtime: { + kind: 'local-host', + hostPlatform: args.appPlatform, + projectId: args.projectId, + reason: 'non-windows', + cacheKey: `${args.projectId}:local-host:${args.appPlatform}` + } + } + } + + const projectPreference = normalizeProjectRuntimePreference(args.projectRuntimePreference) + if (projectPreference.kind === 'windows-host') { + return resolvedWindowsHost(args.projectId, 'project-override') + } + + if (projectPreference.kind === 'wsl') { + return resolveWslRuntime(args, projectPreference.distro, 'project-override') + } + + const globalDefault = normalizeGlobalWindowsRuntimeDefault(args.globalWindowsRuntimeDefault) + if (globalDefault.kind === 'wsl') { + return resolveWslRuntime(args, globalDefault.distro, 'global-default') + } + + return resolvedWindowsHost(args.projectId, 'global-default') +} + +function resolveWslRuntime( + args: ResolveProjectExecutionRuntimeArgs, + distro: string | null, + source: RuntimeSource +): ProjectExecutionRuntimeResolution { + if (!distro) { + return { + status: 'repair-required', + repair: { + projectId: args.projectId, + preferredRuntime: { kind: 'wsl', distro }, + reason: 'wsl-distro-required', + source, + cacheKey: `${args.projectId}:repair:wsl-distro-required:default` + } + } + } + + const repairReason = getWslRepairReason(distro, { + wslAvailable: args.wslAvailable, + availableWslDistros: args.availableWslDistros + }) + if (repairReason) { + return { + status: 'repair-required', + repair: { + projectId: args.projectId, + preferredRuntime: { kind: 'wsl', distro }, + reason: repairReason, + source, + cacheKey: `${args.projectId}:repair:${repairReason}:${distro ?? 'default'}` + } + } + } + + const resolvedDistro = distro + return { + status: 'resolved', + runtime: { + kind: 'wsl', + hostPlatform: 'wsl', + projectId: args.projectId, + distro: resolvedDistro, + reason: source, + cacheKey: `${args.projectId}:wsl:${resolvedDistro}` + } + } +} + +function resolvedWindowsHost( + projectId: string, + reason: 'project-override' | 'global-default' | 'migration-fallback' +): ProjectExecutionRuntimeResolution { + return { + status: 'resolved', + runtime: { + kind: 'windows-host', + hostPlatform: 'win32', + projectId, + reason, + cacheKey: `${projectId}:windows-host` + } + } +} + +function getLegacyWslFallbackReason( + distro: string | null, + context: LegacyWindowsRuntimeMigrationContext +): LegacyWindowsRuntimeFallbackReason | null { + if (context.wslAvailable === false) { + return 'legacy-wsl-unavailable' + } + if (distro && isKnownMissingDistro(distro, context.availableWslDistros)) { + return 'legacy-wsl-distro-missing' + } + return null +} + +function getWslRepairReason( + distro: string, + context: LegacyWindowsRuntimeMigrationContext +): ProjectExecutionRuntimeRepairReason | null { + if (context.wslAvailable === false) { + return 'wsl-unavailable' + } + if (isKnownMissingDistro(distro, context.availableWslDistros)) { + return 'wsl-distro-missing' + } + return null +} + +function isKnownMissingDistro( + distro: string, + availableWslDistros: readonly string[] | null | undefined +): boolean { + return Array.isArray(availableWslDistros) && !availableWslDistros.includes(distro) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function normalizeDistro(value: unknown): string | null { + if (typeof value !== 'string') { + return null + } + const trimmed = value.trim() + return trimmed ? trimmed : null +} + +function isWslShell(value: unknown): boolean { + if (typeof value !== 'string') { + return false + } + const shellName = value.trim().split(/[\\/]/).pop()?.toLowerCase() + return shellName === 'wsl.exe' || shellName === 'wsl' +} diff --git a/src/shared/secure-file.test.ts b/src/shared/secure-file.test.ts index c7f479bec1e..7259d5f92f1 100644 --- a/src/shared/secure-file.test.ts +++ b/src/shared/secure-file.test.ts @@ -11,6 +11,8 @@ import { writeSecureFile } from './secure-file' +const posixModeIt = process.platform === 'win32' ? it.skip : it + vi.mock('child_process', () => ({ execFileSync: vi.fn(), execFile: vi.fn() @@ -333,7 +335,7 @@ describe('hardenSecurePath', () => { expect(getSyncPowerShellCalls()).toHaveLength(0) }) - it('re-hardens a POSIX directory when its metadata changes after caching', () => { + posixModeIt('re-hardens a POSIX directory when its metadata changes after caching', () => { Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) const userDataPath = mkdtempSync(join(tmpdir(), 'orca-secure-file-')) tempDirs.push(userDataPath) diff --git a/src/shared/skills.ts b/src/shared/skills.ts index e9220f64a20..eb320e4ab94 100644 --- a/src/shared/skills.ts +++ b/src/shared/skills.ts @@ -1,3 +1,5 @@ +import type { ProjectExecutionRuntimeResolution } from './project-execution-runtime' + export type SkillProvider = 'codex' | 'claude' | 'agent-skills' export type SkillSourceKind = 'home' | 'repo' | 'bundled' | 'plugin' @@ -36,6 +38,7 @@ export type SkillDiscoveryResult = { export type SkillDiscoveryTarget = { runtime?: 'host' | 'wsl' wslDistro?: string | null + projectRuntime?: ProjectExecutionRuntimeResolution } export type SkillFrontmatterSummary = { diff --git a/src/shared/text-search.ts b/src/shared/text-search.ts index e5e6902ec57..ce004e2d303 100644 --- a/src/shared/text-search.ts +++ b/src/shared/text-search.ts @@ -19,7 +19,7 @@ * large repos. Centralizing the policy prevents future drift. Both call * sites must use this module; see filesystem.ts and relay/fs-handler.ts. */ -import { join, relative } from 'path' +import { posix, win32 } from 'path' import { normalizeSearchResult } from './search-match-count' import { escapeRegex } from './string-utils' import type { SearchFileResult, SearchMatch, SearchOptions, SearchResult } from './types' @@ -45,6 +45,21 @@ export function normalizeRelativePath(path: string): string { return path.replace(/[\\/]+/g, '/').replace(/^\/+/, '') } +function pathFlavor(rootPath: string): typeof posix | typeof win32 { + if (/^[a-zA-Z]:[\\/]/.test(rootPath) || rootPath.startsWith('\\\\')) { + return win32 + } + return posix +} + +function relativeToSearchRoot(rootPath: string, absPath: string): string { + return pathFlavor(rootPath).relative(rootPath, absPath) +} + +function joinSearchRoot(rootPath: string, relPath: string): string { + return pathFlavor(rootPath).join(rootPath, relPath) +} + // ─── Constants shared by both callers ──────────────────────────────── export const MAX_MATCHES_PER_FILE = 100 @@ -188,7 +203,7 @@ export function splitSearchGlobPatterns(patterns: string): string[] { * target to a WSL-native path on the local side. On Windows/WSL, only the * rg *invocation* is routed through `wslAwareSpawn`; the target string keeps * its original shape, and rg's output paths are translated back to Windows - * UNC via the `transformAbsPath` callback in `ingestRgJsonLine`. + * paths via the `transformAbsPath` callback in `ingestRgJsonLine`. */ export function buildRgArgs(query: string, target: string, opts: SearchOptionsLike): string[] { const args: string[] = [ @@ -273,7 +288,7 @@ export function ingestRgJsonLine( return 'continue' } const absPath = transformAbsPath ? transformAbsPath(rawPath) : rawPath - const relPath = normalizeRelativePath(relative(rootPath, absPath)) + const relPath = normalizeRelativePath(relativeToSearchRoot(rootPath, absPath)) const lineContent = (data.lines?.text ?? '').replace(/\n$/, '') const lineNumber = data.line_number ?? 0 let submatches = data.submatches ?? [] @@ -434,7 +449,7 @@ export function ingestGitGrepLine( } const lineNum = Number(lineNumberText) - const absPath = join(rootPath, relPath) + const absPath = joinSearchRoot(rootPath, relPath) const getFileResult = (): SearchFileResult => { let fileResult = acc.fileMap.get(absPath) if (!fileResult) { diff --git a/src/shared/types.ts b/src/shared/types.ts index 843879694ce..21c63d81ee9 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -34,6 +34,10 @@ import type { ClaudeAgentTeamsMode } from './claude-agent-teams-tmux-compat' import type { TerminalCustomTheme } from './terminal-custom-themes' import type { UiLanguage } from './ui-language' import type { ForkSyncMode } from './git-fork-sync' +import type { + GlobalWindowsRuntimeDefault, + LocalWindowsRuntimePreference +} from './project-execution-runtime' // Re-exported for backward compat with renderer call sites that import // `WorkspaceCreateTelemetrySource` from '../../../shared/types'. @@ -104,11 +108,18 @@ export type Project = { repoIcon?: RepoIcon | null kind?: RepoKind providerIdentity?: ProjectProviderIdentity + /** Local Windows projects inherit the global runtime default unless this override is set. */ + localWindowsRuntimePreference?: LocalWindowsRuntimePreference sourceRepoIds: string[] createdAt: number updatedAt: number } +export type ProjectUpdateArgs = { + projectId: string + updates: Partial> +} + export type ProjectHostSetupState = 'ready' | 'not-set-up' | 'setting-up' | 'error' | 'unsupported' export type ProjectHostSetupMethod = | 'legacy-repo' @@ -1111,6 +1122,7 @@ export type GitHubPRRefreshCandidate = GitHubPRRefreshAlias & { cachedChecksStatus?: CheckStatus | null cachedMergeable?: PRMergeableState | null cachedMergeStateStatus?: string | null + localGitOptions?: { wslDistro?: string } } export type GitHubPRRefreshSkippedReason = @@ -2402,6 +2414,8 @@ export type GlobalSettings = { * changing the default terminal shell. */ localAgentRuntime?: 'host' | 'wsl' localAgentWslDistro?: string | null + /** Why: global is only the default policy; project-level runtime preference wins. */ + localWindowsRuntimeDefault: GlobalWindowsRuntimeDefault /** Why: "PowerShell" is the product-facing shell family. Auto resolves to * PowerShell 7+ when present and falls back to inbox Windows PowerShell. */ terminalWindowsPowerShellImplementation: 'auto' | 'powershell.exe' | 'pwsh.exe' diff --git a/src/shared/wsl-login-shell-command.test.ts b/src/shared/wsl-login-shell-command.test.ts index 510200ac9a0..b24499e98a4 100644 --- a/src/shared/wsl-login-shell-command.test.ts +++ b/src/shared/wsl-login-shell-command.test.ts @@ -1,10 +1,27 @@ +import { execFileSync } from 'child_process' import { describe, expect, it } from 'vitest' import { buildWslInteractiveLoginShellCommand, buildWslLoginShellCommand, + escapeWslShCommandForWindows, quotePosixShell } from './wsl-login-shell-command' +function expectValidShSyntax(command: string): void { + try { + execFileSync('sh', ['-n'], { input: command }) + return + } catch (error) { + if ( + process.platform !== 'win32' || + !(error instanceof Error && 'code' in error && error.code === 'ENOENT') + ) { + throw error + } + } + execFileSync('wsl.exe', ['--', 'sh', '-n'], { input: command }) +} + describe('wsl login shell command helpers', () => { it('quotes single quotes for POSIX shell arguments', () => { expect(quotePosixShell("a'b")).toBe("'a'\\''b'") @@ -18,6 +35,54 @@ describe('wsl login shell command helpers', () => { expect(command).toContain("printf '\\''hello'\\''") }) + it('preserves command-scoped environment variables through the outer WSL shell', () => { + const command = buildWslLoginShellCommand('HISTFILE=/tmp/orca-history printf "$HISTFILE"') + const escaped = escapeWslShCommandForWindows(command) + + expect(command).toContain('\'HISTFILE=/tmp/orca-history printf "$HISTFILE"\'') + expect(escaped).toContain('\\$_orca_wsl_shell') + expect(escaped).toContain('\\${SHELL:-/bin/bash}') + expect(escaped).toContain('\\$(getent passwd "\\$(id -un)"') + expect(escaped).toContain('\\$HISTFILE') + expectValidShSyntax(command) + }, 15_000) + + it('does not double-escape wrapper shell variables', () => { + const command = 'echo \\$_orca_wsl_shell "$_orca_wsl_shell"' + + expect(escapeWslShCommandForWindows(command)).toBe( + 'echo \\$_orca_wsl_shell "\\$_orca_wsl_shell"' + ) + }) + + it('escapes user command dollars inside POSIX-quoted payloads for WSL argv', () => { + const command = buildWslLoginShellCommand( + 'HISTFILE=/tmp/orca-history printf "$HISTFILE"; printf \'%s\' "$SHELL"' + ) + const escaped = escapeWslShCommandForWindows(command) + + expect(escaped).toContain( + "'HISTFILE=/tmp/orca-history printf \"\\$HISTFILE\"; printf '\\''%s'\\'' \"\\$SHELL\"'" + ) + expectValidShSyntax(command) + }, 15_000) + + it('preserves user command variables across the Windows-to-WSL argv boundary', () => { + if (process.platform !== 'win32') { + return + } + try { + execFileSync('wsl.exe', ['--', 'true']) + } catch { + return + } + + const command = buildWslLoginShellCommand('orca_value=ok; printf "<%s>" "$orca_value"') + const escaped = escapeWslShCommandForWindows(command) + + expect(execFileSync('wsl.exe', ['--', 'sh', '-lc', escaped], { encoding: 'utf8' })).toBe('') + }, 15_000) + it('starts an interactive login shell without assuming bash', () => { const command = buildWslInteractiveLoginShellCommand() diff --git a/src/shared/wsl-login-shell-command.ts b/src/shared/wsl-login-shell-command.ts index a27393a871a..93e29cb16fc 100644 --- a/src/shared/wsl-login-shell-command.ts +++ b/src/shared/wsl-login-shell-command.ts @@ -3,7 +3,18 @@ export function quotePosixShell(value: string): string { } export function escapeWslShCommandForWindows(command: string): string { - return command.replace(/\$/g, '\\$') + // WSL preprocesses unescaped $ in Windows argv before the WSL-side shell + // sees it, even when the POSIX script text would single-quote the dollar. + let escaped = '' + for (let index = 0; index < command.length; index += 1) { + const char = command[index] + if (char === '$' && command[index - 1] !== '\\') { + escaped += '\\$' + continue + } + escaped += char + } + return escaped } export function buildWslLoginShellCommand(command: string): string { diff --git a/tests/e2e/windows-project-runtime-smoke.spec.ts b/tests/e2e/windows-project-runtime-smoke.spec.ts new file mode 100644 index 00000000000..32d415eb4a5 --- /dev/null +++ b/tests/e2e/windows-project-runtime-smoke.spec.ts @@ -0,0 +1,184 @@ +import { execFileSync } from 'child_process' +import { mkdirSync, rmSync, writeFileSync } from 'fs' +import { mkdtemp } from 'fs/promises' +import os from 'os' +import path from 'path' +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { waitForSessionReady } from './helpers/store' + +const tempRoots: string[] = [] +const CLEANUP_RETRY_COUNT = 6 + +async function removeTempRoot(root: string): Promise { + for (let attempt = 0; attempt < CLEANUP_RETRY_COUNT; attempt += 1) { + try { + rmSync(root, { recursive: true, force: true }) + return + } catch (error) { + if (attempt === CLEANUP_RETRY_COUNT - 1) { + throw error + } + // Why: Windows and WSL-backed git probes can release repo handles shortly + // after the Electron fixture closes; retry to keep the smoke idempotent. + await new Promise((resolve) => setTimeout(resolve, 250 * (attempt + 1))) + } + } +} + +async function createGitRepo(prefix: string, repoName: string): Promise { + const rootPath = await mkdtemp(path.join(os.tmpdir(), prefix)) + tempRoots.push(rootPath) + const repoPath = path.join(rootPath, repoName) + mkdirSync(repoPath, { recursive: true }) + execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' }) + execFileSync('git', ['config', 'user.email', 'e2e@test.local'], { + cwd: repoPath, + stdio: 'pipe' + }) + execFileSync('git', ['config', 'user.name', 'E2E Test'], { cwd: repoPath, stdio: 'pipe' }) + writeFileSync(path.join(repoPath, 'README.md'), `# ${repoName}\n`) + execFileSync('git', ['add', 'README.md'], { cwd: repoPath, stdio: 'pipe' }) + execFileSync('git', ['commit', '-m', 'Initial commit'], { cwd: repoPath, stdio: 'pipe' }) + execFileSync('git', ['branch', '-M', 'main'], { cwd: repoPath, stdio: 'pipe' }) + return repoPath +} + +async function openRepoSettings(page: Page, repoId: string): Promise { + await page.evaluate((nextRepoId) => { + const state = window.__store!.getState() + state.setSettingsSearchQuery('') + state.openSettingsTarget({ pane: 'repo', repoId: nextRepoId }) + state.openSettingsPage() + }, repoId) + await expect(page.getByPlaceholder('Search settings')).toBeVisible({ timeout: 10_000 }) + const maybeLaterButton = page.getByRole('button', { name: 'Maybe Later' }) + if (await maybeLaterButton.isVisible({ timeout: 1_000 }).catch(() => false)) { + await maybeLaterButton.click() + } +} + +async function chooseProjectRuntime( + page: Page, + repoId: string, + runtimeLabel: 'Windows' | 'WSL' +): Promise { + const section = page.locator(`[data-settings-section="repo-${repoId}"]`) + await section.getByRole('radio', { name: runtimeLabel, exact: true }).click() + const applyButton = section.getByRole('button', { name: 'Apply runtime change' }) + if (await applyButton.isVisible({ timeout: 1_000 }).catch(() => false)) { + await applyButton.click() + } +} + +test.afterAll(async () => { + for (const root of tempRoots.splice(0)) { + await removeTempRoot(root) + } +}) + +test.describe('Windows project runtime smoke', () => { + test('keeps a Windows-host project and WSL project available side by side', async ({ + orcaPage, + testRepoPath + }) => { + test.skip(process.platform !== 'win32', 'Windows project runtime smoke requires Windows') + await waitForSessionReady(orcaPage) + + const wsl = await orcaPage.evaluate(async () => ({ + available: await window.api.wsl.isAvailable(), + distros: await window.api.wsl.listDistros() + })) + test.skip(!wsl.available || wsl.distros.length === 0, 'WSL distro is required for smoke') + const wslDistro = wsl.distros[0]! + const wslRepoPath = await createGitRepo('orca-e2e-project-runtime-', 'wsl-runtime-project') + + const smoke = await orcaPage.evaluate( + async ({ hostRepoPath, wslRepoPath, wslDistro }) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + + await window.api.repos.add({ path: wslRepoPath }) + await store.getState().fetchRepos() + const state = store.getState() + const hostRepo = state.repos.find((repo) => repo.path === hostRepoPath) + const wslRepo = state.repos.find((repo) => repo.path === wslRepoPath) + if (!hostRepo || !wslRepo) { + throw new Error('Expected host and WSL smoke repos to be loaded') + } + + const hostProject = state.projects.find((project) => + project.sourceRepoIds.includes(hostRepo.id) + ) + const wslProject = state.projects.find((project) => + project.sourceRepoIds.includes(wslRepo.id) + ) + if (!hostProject || !wslProject) { + throw new Error('Expected host and WSL smoke projects to be loaded') + } + + await state.updateProject(hostProject.id, { + localWindowsRuntimePreference: { kind: 'windows-host' } + }) + await state.updateProject(wslProject.id, { + localWindowsRuntimePreference: { kind: 'wsl', distro: wslDistro } + }) + + const hostWorktrees = await window.api.worktrees.listDetected({ repoId: hostRepo.id }) + const wslWorktrees = await window.api.worktrees.listDetected({ repoId: wslRepo.id }) + return { + hostRepoId: hostRepo.id, + wslRepoId: wslRepo.id, + wslDistro, + hostWorktreeCount: hostWorktrees.worktrees.length, + wslWorktreeCount: wslWorktrees.worktrees.length + } + }, + { hostRepoPath: testRepoPath, wslRepoPath, wslDistro } + ) + + expect(smoke.hostWorktreeCount).toBeGreaterThan(0) + expect(smoke.wslWorktreeCount).toBeGreaterThan(0) + + await openRepoSettings(orcaPage, smoke.hostRepoId) + const hostSection = orcaPage.locator(`[data-settings-section="repo-${smoke.hostRepoId}"]`) + await expect(hostSection.getByText('Project Runtime')).toBeVisible() + await expect(hostSection.getByText('This project runs on Windows.')).toBeVisible() + await chooseProjectRuntime(orcaPage, smoke.hostRepoId, 'WSL') + await expect( + hostSection.getByText(`This project runs in ${smoke.wslDistro} via WSL.`) + ).toBeVisible() + const hostAfterWslUiSwitch = await orcaPage.evaluate((hostRepoId) => { + const state = window.__store!.getState() + const hostProject = state.projects.find((project) => + project.sourceRepoIds.includes(hostRepoId) + ) + return hostProject?.localWindowsRuntimePreference + }, smoke.hostRepoId) + expect(hostAfterWslUiSwitch).toEqual({ kind: 'wsl', distro: smoke.wslDistro }) + + await chooseProjectRuntime(orcaPage, smoke.hostRepoId, 'Windows') + await expect(hostSection.getByText('This project runs on Windows.')).toBeVisible() + const hostAfterWindowsUiSwitch = await orcaPage.evaluate((hostRepoId) => { + const state = window.__store!.getState() + const hostProject = state.projects.find((project) => + project.sourceRepoIds.includes(hostRepoId) + ) + return hostProject?.localWindowsRuntimePreference + }, smoke.hostRepoId) + expect(hostAfterWindowsUiSwitch).toEqual({ kind: 'windows-host' }) + + await openRepoSettings(orcaPage, smoke.wslRepoId) + const wslSection = orcaPage.locator(`[data-settings-section="repo-${smoke.wslRepoId}"]`) + await expect(wslSection.getByText('Project Runtime')).toBeVisible() + await expect( + wslSection.getByText(`This project runs in ${smoke.wslDistro} via WSL.`) + ).toBeVisible() + + await orcaPage.evaluate(async (repoId) => { + await window.api.repos.remove({ repoId }) + }, smoke.wslRepoId) + }) +})