From cf5f3f50704cfb161274061f45e9d9d75ff45fad Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:32:57 -0700 Subject: [PATCH] refactor(git): split oversized repository services --- config/max-lines-baseline.txt | 7 - .../browser/cdp-bridge-integration.test.ts | 2 +- .../ephemeral-vm-provisioned-root-source.ts | 2 +- src/main/git/add-sparse-worktree.test.ts | 5 +- src/main/git/base-ref-search.ts | 239 ++ src/main/git/branch-comparison.ts | 211 ++ src/main/git/branch-conflict-detection.ts | 69 + src/main/git/checkout.ts | 2 +- src/main/git/command-resolution.ts | 348 +++ src/main/git/commit-comparison.ts | 195 ++ src/main/git/commit.test.ts | 2 +- src/main/git/conflict-status.ts | 160 ++ src/main/git/default-base-ref.ts | 120 + .../git/effective-upstream-status-cache.ts | 300 +++ src/main/git/exec-file-capture.ts | 155 ++ src/main/git/git-blob-content.ts | 178 ++ src/main/git/git-change-entry-parser.ts | 41 + src/main/git/git-command-resolution.ts | 116 + src/main/git/git-commit-operations.ts | 84 + src/main/git/git-comparison-refs.ts | 53 + src/main/git/git-diff-read-cache.ts | 17 + src/main/git/git-discard-changes.ts | 184 ++ src/main/git/git-environment-policy.ts | 232 ++ src/main/git/git-index-mutations.ts | 101 + src/main/git/git-literal-pathspec.ts | 7 + src/main/git/git-process-launch.ts | 90 + src/main/git/git-read-cache.ts | 47 + src/main/git/git-stream.ts | 223 ++ src/main/git/git-username.ts | 6 +- src/main/git/github-cli-policy.ts | 297 +++ src/main/git/github-cli-runner.ts | 112 + src/main/git/gitlab-cli-runner.ts | 111 + src/main/git/remote-drift.ts | 52 + src/main/git/remote.ts | 2 +- .../remove-worktree-branch-cleanup.test.ts | 8 +- .../remove-worktree-clean-preflight.test.ts | 10 +- src/main/git/remove-worktree-test-harness.ts | 2 +- src/main/git/remove-worktree.test.ts | 8 +- .../git/repo-default-base-timeout.test.ts | 3 +- src/main/git/repo-remote-drift-real.test.ts | 2 +- src/main/git/repo-remote-drift.test.ts | 2 +- src/main/git/repo-search-ref-compat.test.ts | 3 +- src/main/git/repo-username.test.ts | 9 +- src/main/git/repo.test.ts | 9 +- src/main/git/repo.ts | 963 +------- src/main/git/repository-git-execution.ts | 10 + src/main/git/repository-marker-detection.ts | 255 ++ src/main/git/repository-remotes.ts | 116 + src/main/git/runner-command-exec.test.ts | 4 +- src/main/git/runner-gh-host-args.test.ts | 2 +- .../git/runner-gh-rate-limit-breaker.test.ts | 2 +- .../runner-windows-host-environment.test.ts | 8 +- src/main/git/runner-wsl-direct-read.test.ts | 4 +- src/main/git/runner-wsl-gh-fallback.test.ts | 4 +- .../runner-wsl-linked-gitdir-windows.test.ts | 12 +- src/main/git/runner.test.ts | 16 +- src/main/git/runner.ts | 1831 +------------- src/main/git/spawn-command-capture.ts | 127 + .../status-branch-compare-real-ref.test.ts | 2 +- src/main/git/status-branch-compare.test.ts | 3 +- ...us-branch-line-total-exec-contract.test.ts | 18 +- .../status-branch-line-total-real-git.test.ts | 3 +- .../git/status-conflict-operations.test.ts | 2 +- .../git/status-conflict-overlap.bench.test.ts | 6 +- src/main/git/status-cquoted-paths.test.ts | 3 +- src/main/git/status-diff.test.ts | 6 +- .../status-discard-and-bulk-staging.test.ts | 9 +- src/main/git/status-discard-symlink.test.ts | 2 +- src/main/git/status-file-diff.ts | 204 ++ src/main/git/status-line-stats.ts | 128 + src/main/git/status-pathspec-literals.test.ts | 2 +- src/main/git/status-query-options.ts | 13 + src/main/git/status-read-coalescing.test.ts | 14 +- .../git/status-submodule-path-cache.test.ts | 5 +- src/main/git/status-submodule.test.ts | 18 +- src/main/git/status-test-harness.ts | 15 + .../status-upstream-negative-cache.test.ts | 18 +- .../git/status-upstream-probe-churn.test.ts | 13 +- src/main/git/status-wsl-pathspecs.test.ts | 10 +- src/main/git/status.test.ts | 14 +- src/main/git/status.ts | 2186 +---------------- src/main/git/submodule-paths.ts | 191 ++ src/main/git/submodule-status.ts | 119 + src/main/git/subprocess-lifecycle.ts | 62 + src/main/git/upstream.test.ts | 2 +- .../git/worktree-add-creation-config.test.ts | 1 + .../worktree-add-local-base-refresh.test.ts | 1 + ...worktree-add-local-base-suggestion.test.ts | 1 + .../git/worktree-add-timeout-override.test.ts | 1 + src/main/git/worktree-base-ref-refresh.ts | 245 ++ src/main/git/worktree-branch-cleanup.ts | 188 ++ src/main/git/worktree-creation.ts | 145 ++ src/main/git/worktree-execution-options.ts | 17 + .../git/worktree-git-capabilities.test.ts | 3 +- src/main/git/worktree-graph-listing.test.ts | 3 +- src/main/git/worktree-list-output.ts | 93 + src/main/git/worktree-list-paths.test.ts | 3 +- src/main/git/worktree-list-porcelain.test.ts | 7 +- src/main/git/worktree-listing.ts | 233 ++ src/main/git/worktree-move.test.ts | 1 + src/main/git/worktree-operation-contracts.ts | 31 + src/main/git/worktree-paths.ts | 124 + .../git/worktree-porcelain-parsing.test.ts | 3 +- src/main/git/worktree-registration-repair.ts | 42 + src/main/git/worktree-removal-preflight.ts | 59 + src/main/git/worktree-removal.ts | 99 + .../worktree-remove-branch-deletion.test.ts | 1 + .../git/worktree-scan-cache-sharing.test.ts | 10 +- .../git/worktree-separate-git-dir.test.ts | 4 +- .../git/worktree-shared-directories.test.ts | 2 +- src/main/git/worktree-sparse-checkout.test.ts | 3 +- src/main/git/worktree-sparse-checkout.ts | 133 + src/main/git/worktree.ts | 1600 +----------- src/main/github/auth-diagnose.test.ts | 4 +- src/main/github/auth-diagnose.ts | 2 +- src/main/github/client-create-pr.test.ts | 1 + src/main/github/client-file-viewed.test.ts | 1 + .../client-issue-origin-preference.test.ts | 1 + src/main/github/client-issue-source.test.ts | 1 + .../client-merge-queue-auto-merge.test.ts | 1 + .../client-merged-pr-visibility.test.ts | 1 + .../github/client-pr-branch-discovery.test.ts | 1 + .../github/client-pr-check-details.test.ts | 1 + src/main/github/client-pr-checks.test.ts | 1 + .../client-pr-comment-reactions.test.ts | 1 + .../github/client-pr-conflict-summary.test.ts | 1 + .../github/client-pr-fallback-number.test.ts | 1 + .../github/client-pr-linked-lookup.test.ts | 1 + .../github/client-pr-local-runtime.test.ts | 1 + src/main/github/client-pr-push-target.test.ts | 1 + src/main/github/client-pr-state.test.ts | 1 + .../github/client-rate-limit-block.test.ts | 1 + ...nt-ssh-provider-execution-boundary.test.ts | 1 + .../github/client-stack-merge-guard.test.ts | 3 + src/main/github/client-test-mocks.ts | 7 +- ...client-tracked-upstream-fork-owner.test.ts | 1 + .../client-tracked-upstream-snapshot.test.ts | 1 + .../client-work-item-check-summary.test.ts | 1 + .../client-work-items-query-paging.test.ts | 1 + src/main/github/client-work-items.test.ts | 1 + .../client/check/check-job-log-tails.ts | 2 +- .../client/check/get-pr-check-details.ts | 3 +- src/main/github/client/check/get-pr-checks.ts | 3 +- .../github/client/check/rerun-pr-checks.ts | 3 +- .../client/create/add-pr-review-comment.ts | 9 +- .../create/create-github-pull-request.ts | 9 +- .../client/create/pull-request-template.ts | 3 +- .../detect/repository-merge-metadata.ts | 2 +- .../github/client/fetch/get-pr-comments.ts | 3 +- .../github/client/fetch/repo-slug-upstream.ts | 3 +- .../github/client/fetch/work-item-fetch.ts | 2 +- .../github/client/list/count-work-items.ts | 2 +- .../github/client/list/work-item-pages.ts | 2 +- .../github/client/lookup/pr-branch-lookup.ts | 2 +- .../github/client/lookup/pr-number-lookup.ts | 2 +- .../client/lookup/pull-request-push-target.ts | 2 +- src/main/github/client/merge/merge-pr.ts | 3 +- src/main/github/client/merge/pr-auto-merge.ts | 9 +- .../client/update/pr-comment-reaction.ts | 3 +- src/main/github/client/update/pr-details.ts | 2 +- .../github/client/update/pr-file-viewed.ts | 3 +- src/main/github/client/update/pr-reviewers.ts | 3 +- src/main/github/client/update/pr-state.ts | 2 +- .../client/update/resolve-review-thread.ts | 3 +- .../github/default-branch-stale-pr.test.ts | 1 + src/main/github/gh-utils.test.ts | 1 + src/main/github/gh-utils.ts | 10 +- .../github-enterprise-repository.test.ts | 1 + .../github/github-enterprise-repository.ts | 2 +- .../github/github-pr-stack-async-merge.ts | 2 +- src/main/github/github-pr-stack.test.ts | 2 +- src/main/github/github-pr-stack.ts | 2 +- ...epository-identity.fork-owner-repo.test.ts | 1 + src/main/github/issue-comment.ts | 3 +- src/main/github/issue-create.ts | 3 +- src/main/github/issue-field-options.ts | 3 +- src/main/github/issue-update.ts | 3 +- src/main/github/issues.test.ts | 1 + src/main/github/issues.ts | 3 +- .../github/merged-pr-commit-membership.ts | 2 +- .../github/pr-refresh-candidate-policy.ts | 155 ++ src/main/github/pr-refresh-coordinator.ts | 954 +------ src/main/github/pr-refresh-github-query.ts | 69 + src/main/github/pr-refresh-pacing.ts | 105 + src/main/github/pr-refresh-queue-drain.ts | 188 ++ src/main/github/pr-refresh-queue-state.ts | 255 ++ .../github/pr-refresh-visible-follow-up.ts | 97 + .../github/project-view-host-auth.test.ts | 7 +- src/main/github/project-view.test.ts | 12 +- src/main/github/project-view.ts | 1683 +------------ .../accessible-project-discovery.ts | 188 ++ src/main/github/project-view/cache-state.ts | 143 ++ src/main/github/project-view/internals.ts | 4 +- .../project-view/issue-comment-mutations.ts | 118 + .../github/project-view/item-page-query.ts | 206 ++ .../github/project-view/item-pagination.ts | 206 ++ .../github/project-view/mutations.test.ts | 4 +- src/main/github/project-view/mutations.ts | 740 +----- .../project-item-field-mutations.ts | 125 + .../github/project-view/project-reference.ts | 212 ++ .../project-view/pull-request-mutations.ts | 48 + .../project-view/repository-issue-types.ts | 129 + .../repository-label-assignee-sources.ts | 122 + .../project-view/response-normalization.ts | 313 +++ .../project-view/view-configuration-query.ts | 258 ++ .../project-view/work-item-details-query.ts | 187 ++ src/main/github/rate-limit.test.ts | 4 +- src/main/github/rate-limit.ts | 2 +- src/main/github/review-head-remote.test.ts | 2 +- src/main/github/review-head-remote.ts | 2 +- src/main/github/stacked-pr-creation.test.ts | 1 + src/main/github/stacked-pr-creation.ts | 3 +- .../work-item-details-concurrency.test.ts | 1 + .../work-item-details-enterprise-host.test.ts | 1 + .../work-item-details-file-viewed.test.ts | 1 + .../github/work-item-details-pr-files.test.ts | 1 + src/main/github/work-item-details.test.ts | 1 + src/main/github/work-item-details.ts | 2 +- .../gitlab/client-mr-auth-rate-limit.test.ts | 1 + .../gitlab/client-mr-branch-lookup.test.ts | 1 + src/main/gitlab/client-mr-job-ci.test.ts | 1 + src/main/gitlab/client-mr-listing.test.ts | 1 + .../gitlab/client-mr-review-actions.test.ts | 1 + src/main/gitlab/client-work-items.test.ts | 1 + src/main/gitlab/client.test.ts | 1 + src/main/gitlab/client.ts | 2 +- src/main/gitlab/gitlab-known-host-probe.ts | 2 +- .../gitlab/gitlab-project-ref-resolution.ts | 2 +- src/main/gitlab/gl-utils.test.ts | 1 + src/main/gitlab/gl-utils.ts | 5 +- src/main/gitlab/issue-update.ts | 2 +- src/main/gitlab/issues.test.ts | 1 + src/main/gitlab/issues.ts | 3 +- .../gitlab/merge-request-creation-lookup.ts | 8 +- .../gitlab/merge-request-creation.test.ts | 1 + src/main/gitlab/merge-request-creation.ts | 9 +- src/main/gitlab/mr-discussion-notes.ts | 2 +- src/main/gitlab/mr-file-diffs.ts | 2 +- src/main/gitlab/mr-reviewers-and-approvals.ts | 2 +- src/main/gitlab/pipeline-job-graph.ts | 2 +- .../gitlab/project-label-and-member-lookup.ts | 2 +- src/main/gitlab/work-item-details.test.ts | 1 + src/main/gitlab/work-item-details.ts | 2 +- src/main/hooks-issue-command.test.ts | 1 + src/main/hooks-setup-runner-script.test.ts | 1 + src/main/hooks.test.ts | 1 + src/main/hooks.ts | 2 +- src/main/index.ts | 2 +- .../filesystem-branch-compare-diff.test.ts | 38 +- ...lesystem-commit-message-generation.test.ts | 38 +- ...tem-commit-message-model-discovery.test.ts | 38 +- .../ipc/filesystem-download-transfers.test.ts | 38 +- .../filesystem-git-commit-dispatch.test.ts | 38 +- .../ipc/filesystem-git-status-staging.test.ts | 38 +- ...list-files-git-directory-expansion.test.ts | 3 + .../ipc/filesystem-list-files-git-fallback.ts | 2 +- ...lesystem-markdown-document-listing.test.ts | 38 +- ...stem-pull-request-field-generation.test.ts | 38 +- src/main/ipc/filesystem-search-git.ts | 2 +- src/main/ipc/filesystem-test-harness.ts | 39 + src/main/ipc/filesystem.test.ts | 38 +- src/main/ipc/filesystem.ts | 28 +- src/main/ipc/github.ts | 33 +- src/main/ipc/preflight-agent-refresh.test.ts | 24 +- .../ipc/repos-add-linked-worktree.test.ts | 8 +- src/main/ipc/repos-create.test.ts | 8 +- .../ipc/repos-execution-host-catalog.test.ts | 8 + .../repos-local-add-and-project-setup.test.ts | 8 + .../ipc/repos-local-clone-lifecycle.test.ts | 17 +- src/main/ipc/repos-nested-import.test.ts | 10 +- src/main/ipc/repos-nested-scan.test.ts | 8 + src/main/ipc/repos-picker.test.ts | 8 +- .../ipc/repos-remote-base-ref-queries.test.ts | 8 + .../ipc/repos-remote-client-events.test.ts | 10 +- .../ipc/repos-remote-git-username.test.ts | 8 + src/main/ipc/repos-remote-test-harness.ts | 34 +- src/main/ipc/repos-remote.test.ts | 10 +- src/main/ipc/repos-sparse-presets.test.ts | 10 +- src/main/ipc/repos.ts | 26 +- src/main/ipc/worktree-remote.ts | 9 +- ...ktrees-create-metadata-persistence.test.ts | 19 +- .../ipc/worktrees-delete-pty-teardown.test.ts | 19 +- .../ipc/worktrees-detected-scan-cache.test.ts | 19 +- ...ktrees-discovery-metadata-backfill.test.ts | 19 +- ...worktrees-existing-branch-checkout.test.ts | 19 +- src/main/ipc/worktrees-forget-local.test.ts | 10 +- .../worktrees-issue-command-overrides.test.ts | 10 +- .../ipc/worktrees-lineage-hydration.test.ts | 19 +- .../worktrees-listing-fallback-rows.test.ts | 19 +- ...orktrees-local-base-ref-resolution.test.ts | 19 +- .../ipc/worktrees-local-create-flow.test.ts | 19 +- ...worktrees-orphan-directory-cleanup.test.ts | 10 +- ...trees-preserved-branch-fork-remote.test.ts | 19 +- .../ipc/worktrees-removal-recovery.test.ts | 19 +- .../worktrees-remove-archive-hooks.test.ts | 19 +- ...rktrees-remove-host-disambiguation.test.ts | 10 +- .../ipc/worktrees-remove-preflight.test.ts | 19 +- ...trees-setup-launch-sparse-checkout.test.ts | 19 +- .../worktrees-ssh-base-ref-resolution.test.ts | 10 +- ...rees-ssh-branch-conflict-suffixing.test.ts | 10 +- ...worktrees-ssh-create-base-prefetch.test.ts | 10 +- ...ktrees-ssh-fork-push-target-remote.test.ts | 10 +- .../worktrees-ssh-local-base-refresh.test.ts | 10 +- .../ipc/worktrees-ssh-pr-head-fetch.test.ts | 10 +- .../worktrees-ssh-provider-authority.test.ts | 10 +- ...orktrees-ssh-repo-owner-resolution.test.ts | 10 +- .../ipc/worktrees-ssh-setup-launch.test.ts | 10 +- src/main/ipc/worktrees-test-module-mocks.ts | 30 +- src/main/ipc/worktrees-windows.test.ts | 18 +- .../ipc/worktrees-wsl-runtime-routing.test.ts | 19 +- src/main/ipc/worktrees.ts | 14 +- .../local-worktree-removal-recovery.test.ts | 2 +- src/main/local-worktree-removal-recovery.ts | 3 +- .../nested-repo-import-target.test.ts | 4 +- .../nested-repo-import-target.ts | 2 +- src/main/repo-worktrees.test.ts | 2 +- src/main/repo-worktrees.ts | 2 +- .../agent-prompt-submission-runtime.test.ts | 2 +- ...pt-submission-windows-submit-delay.test.ts | 2 +- .../runtime/fit-override-integration.test.ts | 16 +- src/main/runtime/mobile-presence-lock.test.ts | 16 +- .../mobile-subscribe-integration.test.ts | 16 +- ...t-navigation-isolation.integration.test.ts | 2 +- .../orca-runtime-git-diff-budget.test.ts | 20 +- src/main/runtime/orca-runtime-git.test.ts | 16 +- src/main/runtime/orca-runtime-git.ts | 26 +- ...linked-issue-live-meta.integration.test.ts | 12 +- src/main/runtime/orca-runtime.test.ts | 70 +- src/main/runtime/orca-runtime.ts | 79 +- .../runtime/remote-desktop-driver.test.ts | 16 +- .../repo-worktree-resolution-scan.test.ts | 4 +- .../runtime/repo-worktree-resolution-scan.ts | 2 +- ...time-rpc-mobile-terminal-streaming.test.ts | 2 +- .../runtime-rpc-worktree-queries.test.ts | 2 +- .../worktree-ps-degraded-repo-scan.test.ts | 2 +- ...rktree-scan-admin-fingerprint-gate.test.ts | 2 +- ...hosted-review-creation-eligibility.test.ts | 2 + ...review-creation-gitlab-self-hosted.test.ts | 3 + ...ed-review-creation-shared-symlinks.test.ts | 2 + .../hosted-review-creation.test.ts | 2 + .../source-control/hosted-review-creation.ts | 9 +- .../source-control/repo-default-branch.ts | 2 +- src/main/worktree-create-base-prefetch.ts | 2 +- src/main/worktree-runner-script.ts | 2 +- src/main/wsl/wsl-executable.ts | 2 + .../child-process/node-process-execution.ts | 51 + 346 files changed, 11315 insertions(+), 10296 deletions(-) create mode 100644 src/main/git/base-ref-search.ts create mode 100644 src/main/git/branch-comparison.ts create mode 100644 src/main/git/branch-conflict-detection.ts create mode 100644 src/main/git/command-resolution.ts create mode 100644 src/main/git/commit-comparison.ts create mode 100644 src/main/git/conflict-status.ts create mode 100644 src/main/git/default-base-ref.ts create mode 100644 src/main/git/effective-upstream-status-cache.ts create mode 100644 src/main/git/exec-file-capture.ts create mode 100644 src/main/git/git-blob-content.ts create mode 100644 src/main/git/git-change-entry-parser.ts create mode 100644 src/main/git/git-command-resolution.ts create mode 100644 src/main/git/git-commit-operations.ts create mode 100644 src/main/git/git-comparison-refs.ts create mode 100644 src/main/git/git-diff-read-cache.ts create mode 100644 src/main/git/git-discard-changes.ts create mode 100644 src/main/git/git-environment-policy.ts create mode 100644 src/main/git/git-index-mutations.ts create mode 100644 src/main/git/git-literal-pathspec.ts create mode 100644 src/main/git/git-process-launch.ts create mode 100644 src/main/git/git-read-cache.ts create mode 100644 src/main/git/git-stream.ts create mode 100644 src/main/git/github-cli-policy.ts create mode 100644 src/main/git/github-cli-runner.ts create mode 100644 src/main/git/gitlab-cli-runner.ts create mode 100644 src/main/git/remote-drift.ts create mode 100644 src/main/git/repository-git-execution.ts create mode 100644 src/main/git/repository-marker-detection.ts create mode 100644 src/main/git/repository-remotes.ts create mode 100644 src/main/git/spawn-command-capture.ts create mode 100644 src/main/git/status-file-diff.ts create mode 100644 src/main/git/status-line-stats.ts create mode 100644 src/main/git/status-query-options.ts create mode 100644 src/main/git/submodule-paths.ts create mode 100644 src/main/git/submodule-status.ts create mode 100644 src/main/git/subprocess-lifecycle.ts create mode 100644 src/main/git/worktree-base-ref-refresh.ts create mode 100644 src/main/git/worktree-branch-cleanup.ts create mode 100644 src/main/git/worktree-creation.ts create mode 100644 src/main/git/worktree-execution-options.ts create mode 100644 src/main/git/worktree-list-output.ts create mode 100644 src/main/git/worktree-listing.ts create mode 100644 src/main/git/worktree-operation-contracts.ts create mode 100644 src/main/git/worktree-paths.ts create mode 100644 src/main/git/worktree-registration-repair.ts create mode 100644 src/main/git/worktree-removal-preflight.ts create mode 100644 src/main/git/worktree-removal.ts create mode 100644 src/main/git/worktree-sparse-checkout.ts create mode 100644 src/main/github/pr-refresh-candidate-policy.ts create mode 100644 src/main/github/pr-refresh-github-query.ts create mode 100644 src/main/github/pr-refresh-pacing.ts create mode 100644 src/main/github/pr-refresh-queue-drain.ts create mode 100644 src/main/github/pr-refresh-queue-state.ts create mode 100644 src/main/github/pr-refresh-visible-follow-up.ts create mode 100644 src/main/github/project-view/accessible-project-discovery.ts create mode 100644 src/main/github/project-view/cache-state.ts create mode 100644 src/main/github/project-view/issue-comment-mutations.ts create mode 100644 src/main/github/project-view/item-page-query.ts create mode 100644 src/main/github/project-view/item-pagination.ts create mode 100644 src/main/github/project-view/project-item-field-mutations.ts create mode 100644 src/main/github/project-view/project-reference.ts create mode 100644 src/main/github/project-view/pull-request-mutations.ts create mode 100644 src/main/github/project-view/repository-issue-types.ts create mode 100644 src/main/github/project-view/repository-label-assignee-sources.ts create mode 100644 src/main/github/project-view/response-normalization.ts create mode 100644 src/main/github/project-view/view-configuration-query.ts create mode 100644 src/main/github/project-view/work-item-details-query.ts create mode 100644 src/main/wsl/wsl-executable.ts create mode 100644 src/shared/child-process/node-process-execution.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 6ee63bbea2a..7fb6be20222 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -22,13 +22,6 @@ inline src/main/codex-accounts/service.ts inline src/main/codex/config-toml-trust.ts inline src/main/codex/hook-service.ts inline src/main/daemon/daemon-init.ts -inline src/main/git/repo.ts -inline src/main/git/runner.ts -inline src/main/git/status.ts -inline src/main/git/worktree.ts -inline src/main/github/pr-refresh-coordinator.ts -inline src/main/github/project-view.ts -inline src/main/github/project-view/mutations.ts inline src/main/github/work-item-details.ts inline src/main/gitlab/client.ts inline src/main/index.ts diff --git a/src/main/browser/cdp-bridge-integration.test.ts b/src/main/browser/cdp-bridge-integration.test.ts index 5a2cd3a8d23..6d178672aa2 100644 --- a/src/main/browser/cdp-bridge-integration.test.ts +++ b/src/main/browser/cdp-bridge-integration.test.ts @@ -19,7 +19,7 @@ vi.mock('electron', () => ({ app: { getPath: vi.fn(() => '/tmp'), isPackaged: false } })) -vi.mock('../git/worktree', () => ({ +vi.mock('../git/worktree-listing', () => ({ listWorktrees: vi.fn().mockResolvedValue([]), listWorktreesStrict: vi.fn().mockResolvedValue([]) })) diff --git a/src/main/ephemeral-vm-provisioned-root-source.ts b/src/main/ephemeral-vm-provisioned-root-source.ts index f3e61d9f8c3..9638578cb40 100644 --- a/src/main/ephemeral-vm-provisioned-root-source.ts +++ b/src/main/ephemeral-vm-provisioned-root-source.ts @@ -1,7 +1,7 @@ import type { Store } from './persistence' import type { Repo } from '../shared/repo-types' import { gitExecFileAsync } from './git/runner' -import { resolveDefaultBaseRefWithLocalGit } from './git/repo' +import { resolveDefaultBaseRefWithLocalGit } from './git/default-base-ref' import { getLocalProjectGitExecOptions } from './project-runtime-git-options' import { resolveWorktreeCreateBase } from './worktree-create-base' import { resolveWorktreeAddBaseRef } from '../shared/worktree/base-ref' diff --git a/src/main/git/add-sparse-worktree.test.ts b/src/main/git/add-sparse-worktree.test.ts index 2541b822438..d62ccd2d520 100644 --- a/src/main/git/add-sparse-worktree.test.ts +++ b/src/main/git/add-sparse-worktree.test.ts @@ -34,9 +34,10 @@ vi.mock('./runner', () => ({ gitExecFileSync: gitExecFileSyncMock, translateWslOutputPaths: translateWslOutputPathsMock })) +vi.mock('./git-process-launch', () => ({ gitExecFileSync: gitExecFileSyncMock })) -vi.mock('./status', () => ({ - resolveGitDir: resolveGitDirMock, +vi.mock('./conflict-status', () => ({ resolveGitDir: resolveGitDirMock })) +vi.mock('./git-read-cache', () => ({ runWithGitReadCacheInvalidation: (run: () => Promise) => run() })) diff --git a/src/main/git/base-ref-search.ts b/src/main/git/base-ref-search.ts new file mode 100644 index 00000000000..7ba4c78581b --- /dev/null +++ b/src/main/git/base-ref-search.ts @@ -0,0 +1,239 @@ +import type { BaseRefSearchResult } from '../../shared/repo-types' +import { isForEachRefExcludeUnsupportedError } from '../../shared/git-ref-command-capabilities' +import { getLocalGitCapabilityCache } from './git-capability-state' +import { gitExecFileAsync } from './runner' + +const REF_SEARCH_CANDIDATE_MULTIPLIER = 4 +const REF_SEARCH_LEGACY_HEADROOM = 100 + +type RefSearchPatternGroup = 'all' | 'segmented' | 'branchRoot' + +function getRefSearchTokens(normalizedQuery: string): string[] { + return normalizedQuery.split('/').filter((token) => token.length > 0) +} + +function getRefSearchCandidateCount(limit: number, excludesRemoteHead: boolean): number { + if (!Number.isInteger(limit) || limit <= 0) { + throw new Error('invalid_limit') + } + const baseCount = limit * REF_SEARCH_CANDIDATE_MULTIPLIER + return excludesRemoteHead ? baseCount : baseCount + REF_SEARCH_LEGACY_HEADROOM +} + +/** Build the Git 2.25-compatible argv for ref search after query normalization. */ +export function buildSearchBaseRefsArgv( + normalizedQuery: string, + limit: number, + options: { + excludeRemoteHead?: boolean + remoteNames?: readonly string[] + patternGroup?: RefSearchPatternGroup + } = {} +): string[] { + const excludeRemoteHead = options.excludeRemoteHead ?? true + const candidateCount = getRefSearchCandidateCount(limit, excludeRemoteHead) + const base = [ + 'for-each-ref', + '--format=%(refname)%00%(refname:short)', + '--sort=-committerdate', + ...(excludeRemoteHead ? ['--exclude=refs/remotes/**/HEAD'] : []), + `--count=${candidateCount}` + ] + const tokens = getRefSearchTokens(normalizedQuery) + if (tokens.length <= 1) { + const query = tokens[0] ?? '' + return [ + ...base, + `refs/heads/**/*${query}*`, + `refs/heads/**/*${query}*/**`, + `refs/remotes/**/*${query}*`, + `refs/remotes/**/*${query}*/**` + ] + } + // One `*token*` per segment lets a retyped `/` display result match. + const segmented = tokens.map((token) => `*${token}*`).join('/') + const substringQuery = tokens.join('/') + const remoteBranchRootPatterns = + options.remoteNames && options.remoteNames.length > 0 + ? options.remoteNames.flatMap((remote) => [ + `refs/remotes/${remote}/${substringQuery}*`, + `refs/remotes/${remote}/${substringQuery}*/**` + ]) + : [`refs/remotes/*/${substringQuery}*`, `refs/remotes/*/${substringQuery}*/**`] + const segmentedPatterns = [`refs/remotes/${segmented}`, `refs/heads/${segmented}`] + const branchRootPatterns = [ + `refs/heads/${substringQuery}*`, + `refs/heads/${substringQuery}*/**`, + ...remoteBranchRootPatterns + ] + const patterns = + options.patternGroup === 'segmented' + ? segmentedPatterns + : options.patternGroup === 'branchRoot' + ? branchRootPatterns + : [...segmentedPatterns, ...branchRootPatterns] + return [...base, ...patterns] +} + +async function runSearchBaseRefsGit( + path: string, + normalizedQuery: string, + limit: number, + options: { remoteNames: readonly string[]; patternGroup?: RefSearchPatternGroup } +): Promise<{ stdout: string }> { + return getLocalGitCapabilityCache({ cwd: path }).runWithFallback( + 'for-each-ref-exclude', + () => + gitExecFileAsync( + buildSearchBaseRefsArgv(normalizedQuery, limit, { + remoteNames: options.remoteNames, + patternGroup: options.patternGroup + }), + { cwd: path } + ), + () => + gitExecFileAsync( + buildSearchBaseRefsArgv(normalizedQuery, limit, { + excludeRemoteHead: false, + remoteNames: options.remoteNames, + patternGroup: options.patternGroup + }), + { cwd: path } + ), + isForEachRefExcludeUnsupportedError + ) +} + +export function mergeBaseRefSearchResultGroups( + groups: readonly BaseRefSearchResult[][], + limit: number +): BaseRefSearchResult[] { + const seen = new Set() + const merged: BaseRefSearchResult[] = [] + const maxLength = Math.max(0, ...groups.map((group) => group.length)) + for (let index = 0; index < maxLength && merged.length < limit; index += 1) { + for (const group of groups) { + const entry = group[index] + if (!entry || seen.has(entry.refName)) { + continue + } + seen.add(entry.refName) + merged.push(entry) + if (merged.length >= limit) { + break + } + } + } + return merged +} + +export async function searchBaseRefs(path: string, query: string, limit = 25): Promise { + return (await searchBaseRefDetails(path, query, limit)).map((entry) => entry.refName) +} + +export async function searchBaseRefDetails( + path: string, + query: string, + limit = 25 +): Promise { + if (!Number.isInteger(limit) || limit <= 0) { + return [] + } + const normalizedQuery = normalizeRefSearchQuery(query) + + try { + const remotes = await listRemoteNames(path) + const tokens = getRefSearchTokens(normalizedQuery) + if (tokens.length > 1) { + const results = await Promise.all([ + runSearchBaseRefsGit(path, normalizedQuery, limit, { + remoteNames: remotes, + patternGroup: 'segmented' + }), + runSearchBaseRefsGit(path, normalizedQuery, limit, { + remoteNames: remotes, + patternGroup: 'branchRoot' + }) + ]) + return mergeBaseRefSearchResultGroups( + results.map((entry) => parseAndFilterSearchRefDetails(entry.stdout, limit, remotes)), + limit + ) + } + + const result = await runSearchBaseRefsGit(path, normalizedQuery, limit, { + remoteNames: remotes + }) + return parseAndFilterSearchRefDetails(result.stdout, limit, remotes) + } catch (err) { + console.warn('[searchBaseRefs] for-each-ref failed', { path, err }) + return [] + } +} + +async function listRemoteNames(path: string): Promise { + try { + const { stdout } = await gitExecFileAsync(['remote'], { cwd: path }) + return stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + } catch { + return [] + } +} + +export function parseAndFilterSearchRefDetails( + stdout: string, + limit: number, + remotes: string[] = [] +): BaseRefSearchResult[] { + const seen = new Set() + const sortedRemotes = [...remotes].sort((left, right) => right.length - left.length) + return stdout + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => { + const nul = line.indexOf('\0') + if (nul === -1) { + return null + } + return { full: line.slice(0, nul), short: line.slice(nul + 1) } + }) + .filter((entry): entry is { full: string; short: string } => entry !== null) + .filter(({ full }) => !/^refs\/remotes\/.+\/HEAD$/.test(full)) + .filter(({ short }) => { + if (seen.has(short)) { + return false + } + seen.add(short) + return true + }) + .map(({ full, short }) => ({ + refName: short, + localBranchName: resolveLocalBranchName(full, short, sortedRemotes) + })) + .slice(0, Math.max(0, limit)) +} + +export function resolveLocalBranchName( + fullRef: string, + shortRef: string, + remotes: readonly string[] +): string { + const remoteRefPrefix = 'refs/remotes/' + if (!fullRef.startsWith(remoteRefPrefix)) { + return shortRef + } + const remoteAndBranch = fullRef.slice(remoteRefPrefix.length) + const remote = remotes.find((candidate) => remoteAndBranch.startsWith(`${candidate}/`)) + if (remote) { + return remoteAndBranch.slice(remote.length + 1) + } + return remoteAndBranch.split('/').slice(1).join('/') || shortRef +} + +export function normalizeRefSearchQuery(query: string): string { + return query.trim().replace(/[*?[\]\\]/g, '') +} diff --git a/src/main/git/branch-comparison.ts b/src/main/git/branch-comparison.ts new file mode 100644 index 00000000000..084550cd799 --- /dev/null +++ b/src/main/git/branch-comparison.ts @@ -0,0 +1,211 @@ +import type { + GitBranchChangeEntry, + GitBranchCompareResult, + GitBranchCompareSummary, + GitDiffResult +} from '../../shared/git-diff-compare-types' +import { parseNumstat } from '../../shared/git-uncommitted-line-stats' +import { readBranchCompareHead } from '../../shared/git-branch-compare-head' +import { resolveWorktreeAddBaseRef } from '../../shared/worktree/base-ref' +import { resolveWorktreeBaseCommitOid } from './worktree-base-ref-probe' +import { gitExecFileAsync } from './runner' +import type { GitRuntimeOptions } from './git-runtime-options' +import { gitOptionsForWorktree } from './git-runtime-options' +import { buildDiffResult, readGitBlobAtOidPath } from './git-blob-content' +import { runGitDiffRead } from './git-diff-read-cache' +import { parseBranchChangeLine } from './git-change-entry-parser' +import { + countAheadCommits, + resolveCompareRef, + resolveMergeBase, + resolveRefOid +} from './git-comparison-refs' + +const MAX_GIT_SHOW_BYTES = 10 * 1024 * 1024 + +export async function getBranchCompare( + worktreePath: string, + baseRef: string, + options: GitRuntimeOptions = {} +): Promise { + const summary: GitBranchCompareSummary = { + baseRef, + baseOid: null, + compareRef: 'HEAD', + headOid: null, + mergeBase: null, + changedFiles: 0, + status: 'loading' + } + + // The base-ref probe peels to a commit. Only branch refs are guaranteed to store + // commits; remote-tracking refs may store annotated tags whose raw oid must be preserved. + const reusableProbedOidByRef = new Map() + const { compareRef, headOidResult, baseOidResult } = await readBranchCompareHead({ + readCompareRef: () => resolveCompareRef(worktreePath, options), + resolveBaseRef: () => + // Why: short refs like "origin/main" can collide with a local branch; use the proven remote-tracking ref. + resolveWorktreeAddBaseRef(baseRef, async (qualifiedRef) => { + const oid = await resolveWorktreeBaseCommitOid(worktreePath, qualifiedRef, options) + if (oid !== null && qualifiedRef.startsWith('refs/heads/')) { + reusableProbedOidByRef.set(qualifiedRef, oid) + } + return oid !== null + }), + readHeadOid: () => resolveRefOid(worktreePath, 'HEAD', options), + readBaseOid: (ref) => { + const reusableOid = reusableProbedOidByRef.get(ref) + return reusableOid === undefined + ? resolveRefOid(worktreePath, ref, options) + : Promise.resolve(reusableOid) + } + }) + summary.compareRef = compareRef + + let headOid = '' + let baseOid = '' + if (headOidResult.ok) { + headOid = headOidResult.oid + summary.headOid = headOid + } else { + if (baseOidResult.ok) { + baseOid = baseOidResult.oid + summary.baseOid = baseOid + // Why: an unborn branch (new remote worktree) has no changes yet; a compare error would look broken. + summary.changedFiles = 0 + summary.commitsAhead = 0 + summary.status = 'ready' + return { summary, entries: [] } + } + summary.status = 'unborn-head' + summary.errorMessage = + 'This branch does not have a committed HEAD yet, so compare-to-base is unavailable.' + return { summary, entries: [] } + } + + if (baseOidResult.ok) { + baseOid = baseOidResult.oid + summary.baseOid = baseOid + } else { + summary.status = 'invalid-base' + summary.errorMessage = `Base ref ${baseRef} could not be resolved in this repository.` + return { summary, entries: [] } + } + + let mergeBase = '' + try { + mergeBase = await resolveMergeBase(worktreePath, baseOid, headOid, options) + summary.mergeBase = mergeBase + } catch { + summary.status = 'no-merge-base' + summary.errorMessage = `This branch and ${baseRef} do not share a merge base, so compare-to-base is unavailable.` + return { summary, entries: [] } + } + + try { + const [entries, commitsAhead] = await Promise.all([ + loadBranchChanges(worktreePath, mergeBase, headOid, options), + countAheadCommits(worktreePath, baseOid, headOid, options) + ]) + summary.changedFiles = entries.length + summary.commitsAhead = commitsAhead + summary.status = 'ready' + return { summary, entries } + } catch (error) { + summary.status = 'error' + summary.errorMessage = error instanceof Error ? error.message : 'Failed to load branch compare' + return { summary, entries: [] } + } +} + +export async function getBranchDiff( + worktreePath: string, + args: { + headOid: string + mergeBase: string + filePath: string + oldPath?: string + }, + options: GitRuntimeOptions = {} +): Promise { + return runGitDiffRead( + ['branchDiff', worktreePath, args.headOid, args.mergeBase, args.filePath, args.oldPath ?? null], + options, + () => loadBranchDiff(worktreePath, args, options) + ) +} + +async function loadBranchDiff( + worktreePath: string, + args: { + headOid: string + mergeBase: string + filePath: string + oldPath?: string + }, + options: GitRuntimeOptions +): Promise { + try { + const leftPath = args.oldPath ?? args.filePath + // Why concurrent: the two sides are independent `git show` spawns, so awaiting + // them in series doubles the latency of every diff the review panel opens. + const [leftBlob, rightBlob] = await Promise.all([ + readGitBlobAtOidPath(worktreePath, args.mergeBase, leftPath, options), + readGitBlobAtOidPath(worktreePath, args.headOid, args.filePath, options) + ]) + + return buildDiffResult( + leftBlob.content, + rightBlob.content, + leftBlob.isBinary, + rightBlob.isBinary, + args.filePath + ) + } catch { + return { + kind: 'text', + originalContent: '', + modifiedContent: '', + originalIsBinary: false, + modifiedIsBinary: false + } + } +} + +async function loadBranchChanges( + worktreePath: string, + mergeBase: string, + headOid: string, + options: GitRuntimeOptions = {} +): Promise { + // Why: core.quotePath=false keeps real UTF-8 paths — see getStatus rationale. + const gitOptions = { + ...gitOptionsForWorktree(worktreePath, options), + maxBuffer: MAX_GIT_SHOW_BYTES + } + // Why: both diffs are independent, so run them concurrently instead of serializing. + const [{ stdout }, { stdout: numstat }] = await Promise.all([ + gitExecFileAsync( + ['-c', 'core.quotePath=false', 'diff', '--name-status', '-M', '-C', mergeBase, headOid], + gitOptions + ), + gitExecFileAsync( + ['-c', 'core.quotePath=false', 'diff', '-z', '--numstat', '-M', '-C', mergeBase, headOid], + gitOptions + ) + ]) + const statsByPath = parseNumstat(numstat) + + const entries: GitBranchChangeEntry[] = [] + // Why: split on /\r?\n/ so Git's CRLF output on Windows leaves no trailing \r in paths. + for (const line of stdout.split(/\r?\n/)) { + if (!line) { + continue + } + const entry = parseBranchChangeLine(line) + if (entry) { + entries.push({ ...entry, ...statsByPath.get(entry.path) }) + } + } + return entries +} diff --git a/src/main/git/branch-conflict-detection.ts b/src/main/git/branch-conflict-detection.ts new file mode 100644 index 00000000000..083c9557617 --- /dev/null +++ b/src/main/git/branch-conflict-detection.ts @@ -0,0 +1,69 @@ +import { gitExecFileAsync } from './runner' +import { resolveLocalBranchName } from './base-ref-search' +import { repositoryGitExecOptions, type LocalGitExecOptions } from './repository-git-execution' + +export type BranchConflictKind = 'local' | 'remote' + +async function hasGitRef( + path: string, + ref: string, + options: LocalGitExecOptions +): Promise { + try { + await gitExecFileAsync(['rev-parse', '--verify', ref], repositoryGitExecOptions(path, options)) + return true + } catch { + return false + } +} + +async function listRemoteNames(path: string, options: LocalGitExecOptions): Promise { + try { + const { stdout } = await gitExecFileAsync(['remote'], repositoryGitExecOptions(path, options)) + return stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + } catch { + return [] + } +} + +export async function getBranchConflictKind( + path: string, + branchName: string, + allowedBaseRef?: string, + options: LocalGitExecOptions = {} +): Promise { + if (await hasGitRef(path, `refs/heads/${branchName}`, options)) { + return 'local' + } + + try { + const remoteNames = (await listRemoteNames(path, options)).sort( + (left, right) => right.length - left.length + ) + const { stdout } = await gitExecFileAsync( + ['for-each-ref', '--format=%(refname)', 'refs/remotes'], + repositoryGitExecOptions(path, options) + ) + const normalizedAllowedRef = allowedBaseRef + ? allowedBaseRef.startsWith('refs/remotes/') + ? allowedBaseRef + : `refs/remotes/${allowedBaseRef}` + : null + const hasRemoteConflict = stdout.split('\n').some((ref) => { + const trimmed = ref.trim() + if (normalizedAllowedRef && trimmed === normalizedAllowedRef) { + return false + } + const shortRef = trimmed.replace(/^refs\/remotes\//, '') + // Git allows slashes in remote names; use the configured list to find the branch boundary. + return resolveLocalBranchName(trimmed, shortRef, remoteNames) === branchName + }) + + return hasRemoteConflict ? 'remote' : null + } catch { + return null + } +} diff --git a/src/main/git/checkout.ts b/src/main/git/checkout.ts index a7b610f93cd..384f699f5fe 100644 --- a/src/main/git/checkout.ts +++ b/src/main/git/checkout.ts @@ -1,7 +1,7 @@ import type { GitRuntimeOptions } from './git-runtime-options' import { gitOptionsForWorktree } from './git-runtime-options' import { gitExecFileAsync } from './runner' -import { runWithGitReadCacheInvalidation } from './status' +import { runWithGitReadCacheInvalidation } from './git-read-cache' /** * Reject branch names git would parse as an option (`-`/`--…`) or that aren't a diff --git a/src/main/git/command-resolution.ts b/src/main/git/command-resolution.ts new file mode 100644 index 00000000000..b2cd75e1e1e --- /dev/null +++ b/src/main/git/command-resolution.ts @@ -0,0 +1,348 @@ +import { getDefaultWslDistro, parseWslPath, type WslPathInfo } from '../wsl' +import { WSL_EXECUTABLE } from '../wsl/wsl-executable' +import { + buildWslCapturedLoginShellCommand, + buildWslExecArgs, + buildWslLoginShellCommand, + quotePosixShell, + type WslCapturedLoginShellCommand +} from '../../shared/wsl-login-shell-command' +import { UNTRANSLATED_GIT_OUTPUT_ENV } from '../../shared/git-output-locale' +import type { WslGitReadEnvironment } from './wsl-git-read-environment' +import { createAbortError } from './subprocess-lifecycle' + +// ─── Core resolution ──────────────────────────────────────────────── + +// Env-assignment prefix for WSL-routed git, where spawn env can't cross the wsl.exe boundary; values are shell-safe unquoted. +const GIT_OUTPUT_LOCALE_SHELL_PREFIX = Object.entries(UNTRANSLATED_GIT_OUTPUT_ENV) + .map(([key, value]) => `${key}=${value}`) + .join(' ') +const GIT_OUTPUT_LOCALE_ENV_ARGS = Object.entries(UNTRANSLATED_GIT_OUTPUT_ENV).map( + ([key, value]) => `${key}=${value}` +) + +export type ResolvedCommand = { + binary: string + args: string[] + cwd: string | undefined + /** Non-null when the command was routed through WSL. */ + wsl: WslPathInfo | null + wslMode: 'direct-git' | 'login-shell' | 'non-login-shell' | null + /** Present only when the caller opted into a fenced login-shell read. */ + captured?: WslCapturedLoginShellCommand +} + +/** + * Translate Windows-style path arguments to Linux paths for commands run in WSL. + * + * Why: callers pass Windows paths as git arguments, which WSL git can't read. + * UNC paths (\\wsl.localhost\…) become native Linux; drive paths (C:\…) → /mnt/c/… + */ +function translateArgsForWsl(args: string[]): string[] { + return args.map(translateArgForWsl) +} + +function translateArgForWsl(arg: string): string { + // WSL UNC path → native linux path + const wslInfo = parseWslPath(arg) + if (wslInfo) { + return wslInfo.linuxPath + } + + // 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 { + for (let i = 0; i < args.length; i++) { + if ( + (args[i] === '--repo' || args[i] === '-R') && + typeof args[i + 1] === 'string' && + args[i + 1].trim() + ) { + return true + } + if (args[i].startsWith('--repo=') || args[i].startsWith('-R=')) { + return args[i].slice(args[i].indexOf('=') + 1).trim().length > 0 + } + if (args[i].startsWith('-R') && args[i].length > 2) { + return args[i].slice(2).trim().length > 0 + } + } + return false +} + +function argsUseGhApiPlaceholders(args: string[]): boolean { + return args.some( + (arg) => arg.includes('{owner}') || arg.includes('{repo}') || arg.includes('{branch}') + ) +} + +function hasExplicitRepoViewTarget(args: string[]): boolean { + const target = args[2] + return ( + args[0] === 'repo' && + args[1] === 'view' && + typeof target === 'string' && + !target.startsWith('-') && + target.includes('/') + ) +} + +function canRunGitHubCliWithoutRepoCwd(args: string[]): boolean { + if (hasExplicitRepoArg(args)) { + return true + } + if (args[0] === 'api') { + return !argsUseGhApiPlaceholders(args) + } + return args[0] === 'auth' || hasExplicitRepoViewTarget(args) +} + +function isMissingCommandInWsl(stderr: string, command: string): boolean { + const s = stderr.toLowerCase() + const c = command.toLowerCase() + return s.includes(`${c}: command not found`) || s.includes(`${c}: not found`) +} + +export function canFallBackToHostGitHubCli( + command: 'gh', + args: string[], + resolved: ResolvedCommand, + stderr: string +): boolean { + return ( + process.platform === 'win32' && + resolved.wsl !== null && + isMissingCommandInWsl(stderr, command) && + canRunGitHubCliWithoutRepoCwd(args) + ) +} + +export function resolveHostGitHubCli(command: 'gh', args: string[]): ResolvedCommand { + return { + binary: command, + args, + // Why: host gh can't use a WSL UNC cwd; we only fall back for commands with explicit repo/API context, so none is needed. + cwd: undefined, + wsl: null, + wslMode: null + } +} + +let defaultWslDistroOverride: string | null = null +let waitForWindowsHostGitEnvironment: (() => Promise) | null = null + +// Why: allow host commands fallback to route through the user's pinned WSL distro when host execution fails. +export function setDefaultWslDistroOverride(distro: string | null): void { + defaultWslDistroOverride = distro +} + +export function configureWindowsHostGitEnvironmentReadiness( + waitUntilReady: (() => Promise) | null +): void { + waitForWindowsHostGitEnvironment = waitUntilReady +} + +function refreshWindowsHostPath(env: NodeJS.ProcessEnv | undefined): NodeJS.ProcessEnv | undefined { + if (!env) { + return undefined + } + const currentPath = process.env.Path ?? process.env.PATH + if (currentPath === undefined) { + return env + } + const next = { ...env } + const pathKeys = Object.keys(next).filter((key) => key.toLowerCase() === 'path') + if (pathKeys.length === 0) { + next[process.env.Path === undefined ? 'PATH' : 'Path'] = currentPath + } else { + for (const key of pathKeys) { + next[key] = currentPath + } + } + return next +} + +export function prepareWindowsHostGitEnvironment( + resolved: ResolvedCommand, + env: NodeJS.ProcessEnv | undefined, + signal?: AbortSignal +): Promise | null { + if ( + process.platform !== 'win32' || + resolved.wsl !== null || + waitForWindowsHostGitEnvironment === null + ) { + return null + } + const ready = waitForWindowsHostGitEnvironment().then(() => refreshWindowsHostPath(env)) + if (!signal) { + return ready + } + if (signal.aborted) { + return Promise.reject(createAbortError()) + } + return new Promise((resolve, reject) => { + let settled = false + const cleanup = (): void => signal.removeEventListener('abort', onAbort) + const onAbort = (): void => { + if (settled) { + return + } + settled = true + cleanup() + reject(createAbortError()) + } + signal.addEventListener('abort', onAbort, { once: true }) + ready.then( + (value) => { + if (settled) { + return + } + settled = true + cleanup() + resolve(value) + }, + (error) => { + if (settled) { + return + } + settled = true + cleanup() + reject(error) + } + ) + }) +} + +export function resolveDefaultWslCli( + command: 'gh' | 'glab', + args: string[] +): ResolvedCommand | null { + const distro = defaultWslDistroOverride ?? getDefaultWslDistro() + return distro ? resolveCommand(command, args, undefined, distro) : null +} + +export function isHostCommandMissing(err: unknown, command: 'gh' | 'glab'): boolean { + if (!err || typeof err !== 'object') { + return false + } + const e = err as { code?: unknown; message?: unknown; syscall?: unknown; path?: unknown } + if (e.code === 'ENOENT') { + return true + } + const message = typeof e.message === 'string' ? e.message.toLowerCase() : '' + return ( + message.includes('enoent') && + (message.includes(command) || e.path === command || e.syscall === 'spawn') + ) +} + +/** + * Resolve whether a command invocation should be routed through wsl.exe. + * + * Why `bash -c "cd … && …"` instead of `--cd`: wsl.exe's --cd fails with + * ERROR_PATH_NOT_FOUND under Node's execFile/spawn in some configs. + */ +export function resolveCommand( + command: string, + args: string[], + cwd: string | undefined, + wslDistroOverride?: string, + options: { + useWslLoginShell?: boolean + captureLoginShellOutput?: boolean + wslGitReadEnvironment?: WslGitReadEnvironment + env?: NodeJS.ProcessEnv + } = {} +): ResolvedCommand { + if (process.platform !== 'win32') { + return { binary: command, args, cwd, wsl: null, wslMode: null } + } + + // Why: global gh callers (rate_limit, listAccessibleProjects) have no cwd to derive a distro from; a distro hint still routes through wsl.exe. + // TODO(wsl-default-distro): no default-distro setting yet, so override-less global gh callers fall back to host gh.exe (ENOENT on WSL-only installs). + const cwdWsl = cwd ? parseWslPath(cwd) : null + const wsl: WslPathInfo | null = + cwdWsl ?? (wslDistroOverride ? { distro: wslDistroOverride, linuxPath: '' } : null) + if (!wsl) { + return { binary: command, args, cwd, wsl: null, wslMode: null } + } + + const translatedArgs = translateArgsForWsl(args) + // Why: env on wsl.exe stays Windows-side (WSLENV forwards only named vars), so the locale must ride the command string (issue #7808). + const localePrefix = command === 'git' ? `${GIT_OUTPUT_LOCALE_SHELL_PREFIX} ` : '' + const escapedCommand = quotePosixShell(command) + // Why: shell-escape each arg to prevent word splitting / glob expansion inside the bash -c string. + const escapedArgs = translatedArgs.map(quotePosixShell) + // Why: prepend `cd &&` for a UNC cwd; skip it when only a distro override was given (global gh needs no cwd). + const linuxCwd = cwdWsl?.linuxPath ?? (cwd && wslDistroOverride ? translateArgForWsl(cwd) : null) + const shellCmd = linuxCwd + ? `cd ${quotePosixShell(linuxCwd)} && ${localePrefix}${escapedCommand} ${escapedArgs.join(' ')}` + : `${localePrefix}${escapedCommand} ${escapedArgs.join(' ')}` + + if (command === 'git' && options.wslGitReadEnvironment) { + const optionalLocks = options.env?.GIT_OPTIONAL_LOCKS + return { + binary: WSL_EXECUTABLE, + args: [ + '-d', + wsl.distro, + '--exec', + '/usr/bin/env', + `PATH=${options.wslGitReadEnvironment.path}`, + `HOME=${options.wslGitReadEnvironment.home}`, + ...GIT_OUTPUT_LOCALE_ENV_ARGS, + ...(optionalLocks !== undefined ? [`GIT_OPTIONAL_LOCKS=${optionalLocks}`] : []), + options.wslGitReadEnvironment.gitPath, + ...(linuxCwd ? ['-C', linuxCwd] : []), + ...translatedArgs + ], + cwd: undefined, + wsl, + wslMode: 'direct-git' + } + } + + if (options.useWslLoginShell) { + // Why opt-in: the login shell is interactive for bash/zsh, so its rc output + // lands on stdout ahead of the payload. Callers that buffer the whole stream + // fence it; streaming consumers (git grep, ls-files -z) must not, because a + // marker would be glued onto their first record. + if (options.captureLoginShellOutput) { + const captured = buildWslCapturedLoginShellCommand(shellCmd) + return { + binary: WSL_EXECUTABLE, + args: buildWslExecArgs(wsl.distro, ['sh', '-lc', captured.command]), + cwd: undefined, + wsl, + wslMode: 'login-shell', + captured + } + } + return { + binary: WSL_EXECUTABLE, + args: buildWslExecArgs(wsl.distro, ['sh', '-lc', buildWslLoginShellCommand(shellCmd)]), + cwd: undefined, + wsl, + wslMode: 'login-shell' + } + } + + return { + binary: WSL_EXECUTABLE, + args: buildWslExecArgs(wsl.distro, ['bash', '-c', shellCmd]), + // Why: the `cd` inside bash -c handles the directory; a UNC cwd on the Node process is redundant and can break Node internals. + cwd: undefined, + wsl, + wslMode: 'non-login-shell' + } +} diff --git a/src/main/git/commit-comparison.ts b/src/main/git/commit-comparison.ts new file mode 100644 index 00000000000..273b7e2c613 --- /dev/null +++ b/src/main/git/commit-comparison.ts @@ -0,0 +1,195 @@ +import type { + GitBranchChangeEntry, + GitCommitCompareResult, + GitDiffResult +} from '../../shared/git-diff-compare-types' +import { parseNumstat } from '../../shared/git-uncommitted-line-stats' +import { parseGitRevListFirstParentOid } from '../../shared/git-rev-list-output' +import { gitExecFileAsync } from './runner' +import type { GitRuntimeOptions } from './git-runtime-options' +import { gitOptionsForWorktree } from './git-runtime-options' +import { buildDiffResult, readGitBlobAtOidPath } from './git-blob-content' +import { runGitDiffRead } from './git-diff-read-cache' +import { parseBranchChangeLine } from './git-change-entry-parser' +import { resolveRefOid } from './git-comparison-refs' + +const MAX_GIT_SHOW_BYTES = 10 * 1024 * 1024 + +export async function getCommitCompare( + worktreePath: string, + commitId: string, + options: GitRuntimeOptions = {} +): Promise { + let commitOid = '' + try { + commitOid = await resolveRefOid(worktreePath, `${commitId}^{commit}`, options) + } catch { + return { + summary: { + commitOid: '', + parentOid: null, + compareRef: commitId, + baseRef: 'parent', + changedFiles: 0, + status: 'invalid-commit', + errorMessage: `Commit ${commitId} could not be resolved in this repository.` + }, + entries: [] + } + } + + const summary = { + commitOid, + parentOid: null as string | null, + compareRef: commitOid.slice(0, 7), + baseRef: 'empty tree', + changedFiles: 0, + status: 'ready' as const + } + + try { + const { stdout } = await gitExecFileAsync( + ['rev-list', '--parents', '-n', '1', commitOid], + gitOptionsForWorktree(worktreePath, options) + ) + const firstParent = parseGitRevListFirstParentOid(stdout) + summary.parentOid = firstParent + summary.baseRef = firstParent ? firstParent.slice(0, 7) : 'empty tree' + + const entries = await loadCommitChanges(worktreePath, summary.parentOid, commitOid, options) + summary.changedFiles = entries.length + return { summary, entries } + } catch (error) { + return { + summary: { + ...summary, + status: 'error', + errorMessage: error instanceof Error ? error.message : 'Failed to load commit diff' + }, + entries: [] + } + } +} + +export async function getCommitDiff( + worktreePath: string, + args: { + commitOid: string + parentOid?: string | null + filePath: string + oldPath?: string + }, + options: GitRuntimeOptions = {} +): Promise { + return runGitDiffRead( + [ + 'commitDiff', + worktreePath, + args.commitOid, + args.parentOid ?? null, + args.filePath, + args.oldPath ?? null + ], + options, + () => loadCommitDiff(worktreePath, args, options) + ) +} + +async function loadCommitDiff( + worktreePath: string, + args: { + commitOid: string + parentOid?: string | null + filePath: string + oldPath?: string + }, + options: GitRuntimeOptions +): Promise { + try { + const leftPath = args.oldPath ?? args.filePath + // Why concurrent: the two sides are independent `git show` spawns. A root + // commit has no parent to read, so that side resolves without a spawn. + const [leftBlob, rightBlob] = await Promise.all([ + args.parentOid + ? readGitBlobAtOidPath(worktreePath, args.parentOid, leftPath, options) + : Promise.resolve({ content: '', isBinary: false }), + readGitBlobAtOidPath(worktreePath, args.commitOid, args.filePath, options) + ]) + + return buildDiffResult( + leftBlob.content, + rightBlob.content, + leftBlob.isBinary, + rightBlob.isBinary, + args.filePath + ) + } catch { + return { + kind: 'text', + originalContent: '', + modifiedContent: '', + originalIsBinary: false, + modifiedIsBinary: false + } + } +} + +async function loadCommitChanges( + worktreePath: string, + parentOid: string | null, + commitOid: string, + options: GitRuntimeOptions = {} +): Promise { + // Why: root commits have no parent tree; diff-tree --root uses git's empty tree, avoiding a hardcoded hash-format-specific oid. + const args = parentOid + ? ['-c', 'core.quotePath=false', 'diff', '--name-status', '-M', '-C', parentOid, commitOid] + : [ + '-c', + 'core.quotePath=false', + 'diff-tree', + '--root', + '--no-commit-id', + '--name-status', + '-r', + '-M', + '-C', + commitOid + ] + const numstatArgs = parentOid + ? ['-c', 'core.quotePath=false', 'diff', '-z', '--numstat', '-M', '-C', parentOid, commitOid] + : [ + '-c', + 'core.quotePath=false', + 'diff-tree', + '-z', + '--root', + '--no-commit-id', + '--numstat', + '-r', + '-M', + '-C', + commitOid + ] + const gitOptions = { + ...gitOptionsForWorktree(worktreePath, options), + maxBuffer: MAX_GIT_SHOW_BYTES + } + // Why: the two git queries are independent, so run them in parallel. + const [{ stdout }, { stdout: numstat }] = await Promise.all([ + gitExecFileAsync(args, gitOptions), + gitExecFileAsync(numstatArgs, gitOptions) + ]) + const statsByPath = parseNumstat(numstat) + + const entries: GitBranchChangeEntry[] = [] + for (const line of stdout.split(/\r?\n/)) { + if (!line) { + continue + } + const entry = parseBranchChangeLine(line) + if (entry) { + entries.push({ ...entry, ...statsByPath.get(entry.path) }) + } + } + return entries +} diff --git a/src/main/git/commit.test.ts b/src/main/git/commit.test.ts index 465488d268f..861333821d0 100644 --- a/src/main/git/commit.test.ts +++ b/src/main/git/commit.test.ts @@ -9,7 +9,7 @@ vi.mock('./runner', () => ({ gitExecFileAsyncBuffer: vi.fn() })) -import { commitChanges } from './status' +import { commitChanges } from './git-commit-operations' describe('commitChanges', () => { beforeEach(() => { diff --git a/src/main/git/conflict-status.ts b/src/main/git/conflict-status.ts new file mode 100644 index 00000000000..db0df4b4d97 --- /dev/null +++ b/src/main/git/conflict-status.ts @@ -0,0 +1,160 @@ +import { existsSync } from 'node:fs' +import { readFile } from 'node:fs/promises' +import * as path from 'node:path' +import type { + GitConflictKind, + GitConflictOperation, + GitFileStatus, + GitStatusEntry +} from '../../shared/git-status-types' +import { decodeGitCQuotedPath } from '../../shared/git-cquoted-path' +import { gitExecFileAsync } from './runner' +import type { GitRuntimeOptions } from './git-runtime-options' +import { gitOptionsForWorktree } from './git-runtime-options' +import { runWithGitReadCacheInvalidation } from './git-read-cache' + +export async function parseUnmergedEntry( + worktreePath: string, + line: string +): Promise { + // Why: porcelain v2 `u` records are space-separated (not tab); path is field 10+ and may contain spaces, so join the tail. + const parts = line.split(' ') + const xy = parts[1] + const modeStage1 = parts[3] + const modeStage2 = parts[4] + const modeStage3 = parts[5] + const filePath = decodeGitCQuotedPath(parts.slice(10).join(' ')) + if (!filePath) { + return null + } + + // Why: submodule conflicts (mode 160000) are out of scope for v1 — they need different resolution UX. + if ([modeStage1, modeStage2, modeStage3].some((mode) => mode === '160000')) { + return null + } + + const conflictKind = parseConflictKind(xy) + if (!conflictKind) { + return null + } + + // Why: porcelain v2 `u` records lack rename-origin metadata, so oldPath is intentionally omitted. + return { + path: filePath, + area: 'unstaged', + status: await getConflictCompatibilityStatus(worktreePath, filePath, conflictKind), + conflictKind, + conflictStatus: 'unresolved' + } +} + +function parseConflictKind(xy: string): GitConflictKind | null { + switch (xy) { + case 'UU': + return 'both_modified' + case 'AA': + return 'both_added' + case 'DD': + return 'both_deleted' + case 'AU': + return 'added_by_us' + case 'UA': + return 'added_by_them' + case 'DU': + return 'deleted_by_us' + case 'UD': + return 'deleted_by_them' + default: + return null + } +} + +// Why: `status` here is a rendering-compat choice for icon/color plumbing, not semantic; the conflict badge carries the real meaning. +// Why: for deleted_by_*/added_by_* variants Git's result depends on merge strategy, so check the filesystem. +async function getConflictCompatibilityStatus( + worktreePath: string, + filePath: string, + conflictKind: GitConflictKind +): Promise { + if (conflictKind === 'both_modified' || conflictKind === 'both_added') { + return 'modified' + } + + if (conflictKind === 'both_deleted') { + return 'deleted' + } + + try { + return existsSync(path.join(worktreePath, filePath)) ? 'modified' : 'deleted' + } catch { + // Why: on an fs check failure, 'modified' is safer — it keeps the row visible rather than falsely showing 'deleted'. + return 'modified' + } +} + +// Why: the git-status → existsSync race can miss a transient HEAD; fall back to 'unknown' for one poll cycle. +// Why: detect rebase from rebase-merge/ or rebase-apply/ dirs (persist all steps), not REBASE_HEAD (partial, lingers → stale badge). +export async function detectConflictOperation(worktreePath: string): Promise { + const gitDir = await resolveGitDir(worktreePath) + const mergeHead = path.join(gitDir, 'MERGE_HEAD') + const cherryPickHead = path.join(gitDir, 'CHERRY_PICK_HEAD') + const rebaseMergeDir = path.join(gitDir, 'rebase-merge') + const rebaseApplyDir = path.join(gitDir, 'rebase-apply') + + let hasMergeHead = false + let hasCherryPickHead = false + let hasRebaseDir = false + + try { + hasMergeHead = existsSync(mergeHead) + hasCherryPickHead = existsSync(cherryPickHead) + hasRebaseDir = existsSync(rebaseMergeDir) || existsSync(rebaseApplyDir) + } catch { + return 'unknown' + } + + if (hasMergeHead) { + return 'merge' + } + if (hasRebaseDir) { + return 'rebase' + } + if (hasCherryPickHead) { + return 'cherry-pick' + } + return 'unknown' +} + +export async function abortMerge( + worktreePath: string, + options: GitRuntimeOptions = {} +): Promise { + await runWithGitReadCacheInvalidation(() => + gitExecFileAsync(['merge', '--abort'], gitOptionsForWorktree(worktreePath, options)) + ) +} + +export async function abortRebase( + worktreePath: string, + options: GitRuntimeOptions = {} +): Promise { + await runWithGitReadCacheInvalidation(() => + gitExecFileAsync(['rebase', '--abort'], gitOptionsForWorktree(worktreePath, options)) + ) +} + +export async function resolveGitDir(worktreePath: string): Promise { + const dotGitPath = path.join(worktreePath, '.git') + + try { + const dotGitContents = await readFile(dotGitPath, 'utf-8') + const match = dotGitContents.match(/^gitdir:\s*(.+)\s*$/m) + if (match) { + return path.resolve(worktreePath, match[1]) + } + } catch { + // `.git` is likely a directory in a non-worktree checkout. + } + + return dotGitPath +} diff --git a/src/main/git/default-base-ref.ts b/src/main/git/default-base-ref.ts new file mode 100644 index 00000000000..7625d143868 --- /dev/null +++ b/src/main/git/default-base-ref.ts @@ -0,0 +1,120 @@ +import { gitExecFileAsync } from './runner' +import { gitExecFileSync } from './git-process-launch' +import { repositoryGitExecOptions, type LocalGitExecOptions } from './repository-git-execution' + +export type GitExec = (argv: string[]) => Promise<{ stdout: string }> + +type LocalDefaultBaseRefGitOptions = { + cwd: string + wslDistro?: string +} + +const DEFAULT_BASE_REF_PROBE_TIMEOUT_MS = 15_000 + +export const DEFAULT_BASE_REF_PROBES: readonly { ref: string; returnAs: string }[] = [ + { ref: 'refs/remotes/origin/main', returnAs: 'origin/main' }, + { ref: 'refs/remotes/origin/master', returnAs: 'origin/master' }, + { ref: 'refs/heads/main', returnAs: 'main' }, + { ref: 'refs/heads/master', returnAs: 'master' } +] + +async function resolveDefaultBaseRefFromProbes( + hasRef: (ref: string) => Promise +): Promise { + for (const { ref, returnAs } of DEFAULT_BASE_REF_PROBES) { + if (await hasRef(ref)) { + return returnAs + } + } + return null +} + +function hasGitRef(path: string, ref: string): boolean { + try { + gitExecFileSync(['rev-parse', '--verify', ref], { cwd: path }) + return true + } catch { + return false + } +} + +function gitRefToDefaultBaseRef(ref: string): string { + return ref.replace(/^refs\/remotes\//, '') +} + +function getVerifiedOriginHeadBaseRef(path: string): string | null { + try { + const ref = gitExecFileSync(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], { + cwd: path + }).trim() + return ref && hasGitRef(path, ref) ? gitRefToDefaultBaseRef(ref) : null + } catch { + return null + } +} + +export function getDefaultBaseRef(path: string): string | null { + const originHeadBaseRef = getVerifiedOriginHeadBaseRef(path) + if (originHeadBaseRef) { + return originHeadBaseRef + } + for (const { ref, returnAs } of DEFAULT_BASE_REF_PROBES) { + if (hasGitRef(path, ref)) { + return returnAs + } + } + return null +} + +export function getBaseRefDefault( + path: string, + options: LocalGitExecOptions = {} +): Promise { + return resolveDefaultBaseRefWithLocalGit(repositoryGitExecOptions(path, options)) +} + +async function hasGitRefViaExec(exec: GitExec, ref: string): Promise { + try { + await exec(['rev-parse', '--verify', '--quiet', ref]) + return true + } catch { + return false + } +} + +async function resolveVerifiedOriginHeadBaseRefViaExec(exec: GitExec): Promise { + try { + const { stdout } = await exec(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD']) + const ref = stdout.trim() + if (!ref || !(await hasGitRefViaExec(exec, ref))) { + return null + } + return gitRefToDefaultBaseRef(ref) + } catch { + return null + } +} + +/** + * Resolve the default base ref via a git exec callback: prefer origin/HEAD's symbolic-ref target, + * else fall back to DEFAULT_BASE_REF_PROBES. Shared local/SSH so both transports agree. + */ +export async function resolveDefaultBaseRefViaExec(exec: GitExec): Promise { + const originHeadBaseRef = await resolveVerifiedOriginHeadBaseRefViaExec(exec) + if (originHeadBaseRef) { + return originHeadBaseRef + } + return resolveDefaultBaseRefFromProbes((ref) => hasGitRefViaExec(exec, ref)) +} + +export function resolveDefaultBaseRefWithLocalGit( + options: LocalDefaultBaseRefGitOptions +): Promise { + return resolveDefaultBaseRefViaExec((argv) => + gitExecFileAsync(argv, { + ...options, + // Async avoids main-thread stalls, but dead local/WSL filesystems still need a bound. + timeout: DEFAULT_BASE_REF_PROBE_TIMEOUT_MS + }) + ) +} diff --git a/src/main/git/effective-upstream-status-cache.ts b/src/main/git/effective-upstream-status-cache.ts new file mode 100644 index 00000000000..0b61bc1c582 --- /dev/null +++ b/src/main/git/effective-upstream-status-cache.ts @@ -0,0 +1,300 @@ +import type { GitUpstreamStatus } from '../../shared/git-status-types' +import { + getEffectiveGitUpstreamStatus, + getGitUpstreamStatusForUpstreamName, + splitRemoteBranchName +} from '../../shared/git-effective-upstream' +import { createGitConfigSnapshotRunner } from '../../shared/git-config-snapshot-runner' +import { gitExecFileAsync } from './runner' +import type { GitRuntimeOptions } from './git-runtime-options' +import { gitStatusReadOptionsForWorktree } from './git-runtime-options' + +const EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_TTL_MS = 5 * 60_000 +const MAX_EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_ENTRIES = 512 +const RESOLVED_UPSTREAM_NAME_CACHE_TTL_MS = 60_000 + +type EffectiveUpstreamStatusCacheEntry = { + expiresAt: number + status: GitUpstreamStatus +} + +type ResolvedUpstreamNameCacheEntry = { + upstreamName: string + expiresAt: number +} + +const resolvedUpstreamNameCache = new Map() +const effectiveUpstreamStatusCache = new Map() +const effectiveUpstreamStatusInFlight = new Map>() +const retiredEffectiveUpstreamStatusInFlight = new Map>() +const effectiveUpstreamStatusWriteGeneration = new Map() + +export function clearEffectiveUpstreamStatusCaches(): void { + effectiveUpstreamStatusCache.clear() + effectiveUpstreamStatusInFlight.clear() + retiredEffectiveUpstreamStatusInFlight.clear() + effectiveUpstreamStatusWriteGeneration.clear() + resolvedUpstreamNameCache.clear() +} + +export function getEffectiveUpstreamStatusCacheCountForTests(): number { + return effectiveUpstreamStatusCache.size +} + +export function getEffectiveUpstreamStatusGenerationCountForTests(): number { + return effectiveUpstreamStatusWriteGeneration.size +} +export function clearResolvedUpstreamNameCache(): void { + resolvedUpstreamNameCache.clear() +} + +export function getShortBranchName(branch: string | undefined): string | null { + const prefix = 'refs/heads/' + return branch?.startsWith(prefix) ? branch.slice(prefix.length) : null +} + +export function getEffectiveUpstreamStatusCacheKey( + worktreePath: string, + branchName: string, + upstreamName: string | undefined, + options: GitRuntimeOptions = {} +): string { + return [worktreePath, options.wslDistro ?? 'host', branchName, upstreamName ?? ''].join('\0') +} + +export function clearEffectiveUpstreamNegativeStatusCache(identity: { + worktreePath: string + branchName: string + upstreamName?: string + options?: GitRuntimeOptions +}): void { + const cacheKey = getEffectiveUpstreamStatusCacheKey( + identity.worktreePath, + identity.branchName, + identity.upstreamName, + identity.options + ) + retireEffectiveUpstreamStatusProbe(cacheKey) + effectiveUpstreamStatusCache.delete(cacheKey) + effectiveUpstreamStatusInFlight.delete(cacheKey) + resolvedUpstreamNameCache.delete(cacheKey) + effectiveUpstreamStatusWriteGeneration.set( + cacheKey, + (effectiveUpstreamStatusWriteGeneration.get(cacheKey) ?? 0) + 1 + ) +} + +function retireEffectiveUpstreamStatusProbe(cacheKey: string): void { + const retiredProbe = effectiveUpstreamStatusInFlight.get(cacheKey) + if (!retiredProbe) { + return + } + retiredEffectiveUpstreamStatusInFlight.set(cacheKey, retiredProbe) + void retiredProbe + .finally(() => { + if (retiredEffectiveUpstreamStatusInFlight.get(cacheKey) === retiredProbe) { + retiredEffectiveUpstreamStatusInFlight.delete(cacheKey) + trimEffectiveUpstreamStatusGeneration() + } + }) + .catch(() => undefined) +} + +function hasPendingEffectiveUpstreamStatusProbe(cacheKey: string): boolean { + return ( + effectiveUpstreamStatusInFlight.has(cacheKey) || + retiredEffectiveUpstreamStatusInFlight.has(cacheKey) + ) +} + +function trimEffectiveUpstreamStatusGeneration(): void { + for (const cacheKey of effectiveUpstreamStatusWriteGeneration.keys()) { + if ( + effectiveUpstreamStatusWriteGeneration.size <= MAX_EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_ENTRIES + ) { + break + } + if (hasPendingEffectiveUpstreamStatusProbe(cacheKey)) { + continue + } + effectiveUpstreamStatusWriteGeneration.delete(cacheKey) + } +} + +function readCachedEffectiveUpstreamStatus( + cacheKey: string, + now: number +): GitUpstreamStatus | undefined { + const entry = effectiveUpstreamStatusCache.get(cacheKey) + if (!entry) { + return undefined + } + if (entry.expiresAt <= now) { + effectiveUpstreamStatusCache.delete(cacheKey) + return undefined + } + return entry.status +} + +function rememberEffectiveUpstreamStatus( + cacheKey: string, + status: GitUpstreamStatus, + now: number, + probedSameNameOriginRef: boolean, + writeGeneration: number +): void { + // Why: hasConfiguredPushTarget gates a write action; re-probe each poll rather than cache a stale positive. + if (status.hasUpstream || status.hasConfiguredPushTarget) { + effectiveUpstreamStatusCache.delete(cacheKey) + effectiveUpstreamStatusWriteGeneration.set(cacheKey, writeGeneration + 1) + trimEffectiveUpstreamStatusGeneration() + return + } + if ((effectiveUpstreamStatusWriteGeneration.get(cacheKey) ?? 0) !== writeGeneration) { + return + } + if (!probedSameNameOriginRef) { + return + } + // Why: cache the negative so a stable no-upstream branch doesn't re-probe every poll (TTL lets push/fetch refs appear). + effectiveUpstreamStatusCache.set(cacheKey, { + status, + expiresAt: now + EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_TTL_MS + }) + while (effectiveUpstreamStatusCache.size > MAX_EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_ENTRIES) { + const oldest = effectiveUpstreamStatusCache.keys().next() + if (oldest.done) { + break + } + effectiveUpstreamStatusCache.delete(oldest.value) + effectiveUpstreamStatusWriteGeneration.delete(oldest.value) + } + trimEffectiveUpstreamStatusGeneration() +} + +export async function readOrProbeEffectiveUpstreamStatus( + cacheKey: string, + worktreePath: string, + branchName: string, + options: GitRuntimeOptions = {}, + bypassCache = false +): Promise { + if (!bypassCache) { + const cached = readCachedEffectiveUpstreamStatus(cacheKey, Date.now()) + if (cached) { + return cached + } + + const inFlight = effectiveUpstreamStatusInFlight.get(cacheKey) + if (inFlight) { + return inFlight + } + } + + // Why: overlapping refreshes at startup — coalesce the upstream probe so a stable missing ref fails once. + const writeGeneration = effectiveUpstreamStatusWriteGeneration.get(cacheKey) ?? 0 + const probe = probeOrRevalidateEffectiveUpstreamStatus( + cacheKey, + worktreePath, + branchName, + options, + bypassCache + ).then((result) => { + rememberEffectiveUpstreamStatus( + cacheKey, + result.status, + Date.now(), + result.probedSameNameOriginRef, + writeGeneration + ) + return result.status + }) + if (!bypassCache) { + effectiveUpstreamStatusInFlight.set(cacheKey, probe) + } + try { + return await probe + } finally { + if (effectiveUpstreamStatusInFlight.get(cacheKey) === probe) { + effectiveUpstreamStatusInFlight.delete(cacheKey) + trimEffectiveUpstreamStatusGeneration() + } + } +} + +async function probeOrRevalidateEffectiveUpstreamStatus( + cacheKey: string, + worktreePath: string, + branchName: string, + options: GitRuntimeOptions = {}, + bypassCache = false +): Promise<{ status: GitUpstreamStatus; probedSameNameOriginRef: boolean }> { + const now = Date.now() + const cached = resolvedUpstreamNameCache.get(cacheKey) + if (cached && (bypassCache || cached.expiresAt <= now)) { + resolvedUpstreamNameCache.delete(cacheKey) + } else if (cached) { + try { + const status = await getGitUpstreamStatusForUpstreamName( + (args) => gitExecFileAsync(args, gitStatusReadOptionsForWorktree(worktreePath, options)), + cached.upstreamName + ) + return { status, probedSameNameOriginRef: false } + } catch (error) { + // Why: an aborted probe says nothing about the ref; don't evict the warm name cache. + if (options.signal?.aborted) { + throw error + } + // Ref deleted or repo state changed — fall through to a full re-resolve. + resolvedUpstreamNameCache.delete(cacheKey) + } + } + const result = await probeEffectiveUpstreamStatus(worktreePath, branchName, options) + if (result.status.hasUpstream && result.status.upstreamName) { + resolvedUpstreamNameCache.set(cacheKey, { + upstreamName: result.status.upstreamName, + expiresAt: Date.now() + RESOLVED_UPSTREAM_NAME_CACHE_TTL_MS + }) + while (resolvedUpstreamNameCache.size > MAX_EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_ENTRIES) { + const oldest = resolvedUpstreamNameCache.keys().next() + if (oldest.done) { + break + } + resolvedUpstreamNameCache.delete(oldest.value) + } + } + return result +} + +async function probeEffectiveUpstreamStatus( + worktreePath: string, + branchName: string, + options: GitRuntimeOptions = {} +): Promise<{ status: GitUpstreamStatus; probedSameNameOriginRef: boolean }> { + let probedSameNameOriginRef = false + const snapshotRunner = createGitConfigSnapshotRunner((args) => + gitExecFileAsync(args, gitStatusReadOptionsForWorktree(worktreePath, options)) + ) + const status = await getEffectiveGitUpstreamStatus((args) => { + if (args[0] === 'rev-parse' && args.includes(`refs/remotes/origin/${branchName}`)) { + probedSameNameOriginRef = true + } + return snapshotRunner(args) + }) + return { status, probedSameNameOriginRef } +} + +export function shouldProbeEffectiveUpstreamStatus( + branch: string | undefined, + upstreamName: string | undefined +): boolean { + const branchName = getShortBranchName(branch) + if (!branchName) { + return false + } + if (!upstreamName) { + return true + } + const parsed = splitRemoteBranchName(upstreamName) + return parsed?.remoteName === 'origin' && parsed.branchName !== branchName +} diff --git a/src/main/git/exec-file-capture.ts b/src/main/git/exec-file-capture.ts new file mode 100644 index 00000000000..f5beb53c732 --- /dev/null +++ b/src/main/git/exec-file-capture.ts @@ -0,0 +1,155 @@ +import { + execNodeFile as execFile, + type ExecutedFileOptions as ExecFileOptions, + type SpawnedProcess as ChildProcess +} from '../../shared/child-process/node-process-execution' +import { recordSubprocessSpawn } from '../diagnostics/main-thread-churn-probe' +import { endSubprocessStdin } from '../../shared/subprocess-stdin-write' +import { createAbortError, killSpawnedCommandTree } from './subprocess-lifecycle' + +// Why: cap execFile output to prevent an uncatchable V8 string overflow; match relay MAX_GIT_BUFFER. +export const DEFAULT_GIT_MAX_BUFFER = 10 * 1024 * 1024 +export type ExecFileCaptureOptions = Omit & { + timeout?: number + stdin?: string +} + +function emptyExecFileOutput(options: ExecFileCaptureOptions): string | Buffer { + return options.encoding === 'buffer' ? Buffer.alloc(0) : '' +} + +function isExecFileResultObject( + value: unknown +): value is { stdout: string | Buffer; stderr: string | Buffer } { + return ( + value !== null && + typeof value === 'object' && + !Buffer.isBuffer(value) && + 'stdout' in value && + 'stderr' in value + ) +} + +export function execFileCapture( + command: string, + args: string[], + options: ExecFileCaptureOptions +): Promise<{ stdout: string | Buffer; stderr: string | Buffer }> { + return new Promise((resolve, reject) => { + if (options.signal?.aborted) { + reject(createAbortError()) + return + } + + let settled = false + let terminating = false + let child: ChildProcess | null = null + let timer: NodeJS.Timeout | null = null + const cleanup = (): void => { + if (timer) { + clearTimeout(timer) + timer = null + } + options.signal?.removeEventListener('abort', onAbort) + } + const finish = ( + error: Error | null, + stdout: string | Buffer = emptyExecFileOutput(options), + stderr: string | Buffer = emptyExecFileOutput(options) + ): void => { + if (settled) { + return + } + settled = true + cleanup() + if (error) { + const enriched = error as Error & { stdout?: string | Buffer; stderr?: string | Buffer } + enriched.stdout ??= stdout + enriched.stderr ??= stderr + reject(enriched) + return + } + resolve({ stdout, stderr }) + } + const onAbort = (): void => { + if (settled || terminating) { + return + } + terminating = true + const abortError = createAbortError() + if (!child) { + terminating = false + finish(abortError) + return + } + void killSpawnedCommandTree(child).then(() => { + terminating = false + finish(abortError) + }) + } + + try { + const spawnStartedAt = performance.now() + // Why: our abort listener owns tree cleanup; Node's signal handler could kill wsl.exe before taskkill sees its children. + child = execFile( + command, + args, + { + cwd: options.cwd, + // Why: git.exe is console-subsystem and Orca's main process owns no + // console, so every spawn without this flashes a conhost that takes + // foreground. Git runs on nearly every interaction (#14543). + windowsHide: true, + encoding: options.encoding, + maxBuffer: options.maxBuffer ?? DEFAULT_GIT_MAX_BUFFER, + env: options.env + }, + (error, stdout, stderr) => { + if (terminating) { + return + } + if (!error && stderr === undefined && isExecFileResultObject(stdout)) { + finish(null, stdout.stdout, stdout.stderr) + return + } + finish(error, stdout, stderr) + } + ) + recordSubprocessSpawn(command, args, performance.now() - spawnStartedAt) + } catch (error) { + finish(error instanceof Error ? error : new Error(String(error))) + return + } + + child.once('error', (error) => { + if (!terminating) { + finish(error) + } + }) + + if (options.stdin !== undefined) { + endSubprocessStdin(child.stdin, options.stdin) + } + + // Why: Node's timeout waits forever on signal-ignoring CLIs; enforce our own deadline with bounded tree cleanup. + if (options.timeout && options.timeout > 0) { + timer = setTimeout(() => { + if (settled || terminating) { + return + } + terminating = true + const timeoutError = new Error(`${command} timed out.`) + if (!child) { + terminating = false + finish(timeoutError) + return + } + void killSpawnedCommandTree(child).then(() => { + terminating = false + finish(timeoutError) + }) + }, options.timeout) + } + options.signal?.addEventListener('abort', onAbort, { once: true }) + }) +} diff --git a/src/main/git/git-blob-content.ts b/src/main/git/git-blob-content.ts new file mode 100644 index 00000000000..e3fc486e189 --- /dev/null +++ b/src/main/git/git-blob-content.ts @@ -0,0 +1,178 @@ +import { readFile, stat } from 'node:fs/promises' +import * as path from 'node:path' +import type { GitDiffResult } from '../../shared/git-diff-compare-types' +import { isBinaryBuffer } from '../../shared/binary-buffer' +import { getLargeDiffRenderLimit } from '../../shared/large-diff-render-limit' +import { isMaxBufferOverflowError } from './max-buffer-overflow' +import { gitExecFileAsyncBuffer } from './runner' +import type { GitRuntimeOptions } from './git-runtime-options' +import { gitOptionsForWorktree } from './git-runtime-options' +const MAX_GIT_SHOW_BYTES = 10 * 1024 * 1024 + +export async function readUnstagedLeftBlob( + worktreePath: string, + filePath: string, + options: GitRuntimeOptions = {} +): Promise { + const indexBlob = await readGitBlobAtIndexPath(worktreePath, filePath, options) + if (indexBlob.exists) { + return indexBlob + } + + return readGitBlobAtOidPath(worktreePath, 'HEAD', filePath, options) +} + +export async function readGitBlobAtIndexPath( + worktreePath: 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}`], { + ...gitOptionsForWorktree(worktreePath, options), + maxBuffer: MAX_GIT_SHOW_BYTES + }) + + return { ...bufferToBlob(stdout, filePath), exists: true } + } catch (error) { + if (isMaxBufferOverflowError(error)) { + return { content: '', isBinary: true, exists: true } + } + return { content: '', isBinary: false, exists: false } + } +} + +export async function readGitBlobAtOidPath( + worktreePath: string, + oid: 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', '--end-of-options', `${oid}:${gitPath}`], + { + ...gitOptionsForWorktree(worktreePath, options), + maxBuffer: MAX_GIT_SHOW_BYTES + } + ) + + return { ...bufferToBlob(stdout, filePath), exists: true } + } catch (error) { + if (isMaxBufferOverflowError(error)) { + return { content: '', isBinary: true, exists: true } + } + return { content: '', isBinary: false, exists: false } + } +} + +export async function readWorkingTreeFile(filePath: string): Promise { + let fileStat + try { + fileStat = await stat(filePath) + } catch (error) { + // Why: only ENOENT is a real deletion; other stat errors are read failures, not absence. + return { + content: '', + isBinary: false, + exists: (error as NodeJS.ErrnoException)?.code !== 'ENOENT' + } + } + if (!fileStat.isFile()) { + return { content: '', isBinary: false, exists: false } + } + if (fileStat.size > MAX_GIT_SHOW_BYTES) { + // Why: mirror git's maxBuffer cap for working-tree reads so readFile can't pull in huge assets. + return { content: '', isBinary: true, exists: true } + } + try { + const buffer = await readFile(filePath) + return bufferToBlob(buffer, filePath) + } catch { + // Why: the file exists but could not be read — a read failure, not a deletion. + return { content: '', isBinary: false, exists: true } + } +} + +function bufferToBlob(buffer: Buffer, filePath?: string): GitBlobReadResult { + const isBinary = isBinaryBuffer(buffer) + // Return base64 for recognized image formats so the renderer can display them + const isPreviewableBinary = filePath + ? !!PREVIEWABLE_BINARY_MIME_TYPES[path.extname(filePath).toLowerCase()] + : false + return { + content: isBinary + ? isPreviewableBinary + ? buffer.toString('base64') + : '' + : buffer.toString('utf-8'), + isBinary, + exists: true + } +} + +export function buildDiffResult( + originalContent: string, + modifiedContent: string, + originalIsBinary: boolean, + modifiedIsBinary: boolean, + filePath?: string +): GitDiffResult { + if (originalIsBinary || modifiedIsBinary) { + const mimeType = filePath + ? PREVIEWABLE_BINARY_MIME_TYPES[path.extname(filePath).toLowerCase()] + : undefined + return { + kind: 'binary', + originalContent, + modifiedContent, + originalIsBinary, + modifiedIsBinary, + // Why: renderer still checks legacy `isImage` before previewing, so set it for PDFs too until the contract is renamed. + ...(mimeType ? { isImage: true, mimeType } : {}) + } as GitDiffResult + } + + // Why: over the render limit, return metadata instead of huge text so the renderer can show fallback UI. + const largeDiffRenderLimit = getLargeDiffRenderLimit({ originalContent, modifiedContent }) + if (largeDiffRenderLimit.limited) { + return { + kind: 'text', + originalContent: '', + modifiedContent: '', + originalIsBinary: false, + modifiedIsBinary: false, + largeDiffRenderLimit + } + } + + return { + kind: 'text', + originalContent, + modifiedContent, + originalIsBinary: false, + modifiedIsBinary: false + } +} + +export type GitBlobReadResult = { + content: string + isBinary: boolean + exists: boolean +} + +const PREVIEWABLE_BINARY_MIME_TYPES: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.svg': 'image/svg+xml', + '.webp': 'image/webp', + '.bmp': 'image/bmp', + '.ico': 'image/x-icon', + '.pdf': 'application/pdf' +} diff --git a/src/main/git/git-change-entry-parser.ts b/src/main/git/git-change-entry-parser.ts new file mode 100644 index 00000000000..48f17c660da --- /dev/null +++ b/src/main/git/git-change-entry-parser.ts @@ -0,0 +1,41 @@ +import type { GitBranchChangeEntry } from '../../shared/git-diff-compare-types' +import type { GitBranchChangeStatus } from '../../shared/git-status-types' +import { decodeGitCQuotedPath } from '../../shared/git-cquoted-path' + +function parseBranchStatusChar(char: string): GitBranchChangeStatus { + switch (char) { + case 'M': + return 'modified' + case 'A': + return 'added' + case 'D': + return 'deleted' + case 'R': + return 'renamed' + case 'C': + return 'copied' + default: + return 'modified' + } +} +export function parseBranchChangeLine(line: string): GitBranchChangeEntry | null { + const parts = line.split('\t') + const rawStatus = parts[0] ?? '' + const status = parseBranchStatusChar(rawStatus[0] ?? 'M') + + if (rawStatus.startsWith('R') || rawStatus.startsWith('C')) { + const oldPath = decodeGitCQuotedPath(parts[1] ?? '') + const path = decodeGitCQuotedPath(parts[2] ?? '') + if (!path) { + return null + } + return { path, oldPath, status } + } + + const path = decodeGitCQuotedPath(parts[1] ?? '') + if (!path) { + return null + } + + return { path, status } +} diff --git a/src/main/git/git-command-resolution.ts b/src/main/git/git-command-resolution.ts new file mode 100644 index 00000000000..a14b24dd3af --- /dev/null +++ b/src/main/git/git-command-resolution.ts @@ -0,0 +1,116 @@ +import { parseWslPath } from '../wsl' +import { isWslDirectGitReadCommand } from './wsl-direct-git-read-commands' +import { + disableWslGitReadEnvironment, + getWslGitReadEnvironment, + invalidateWslGitReadEnvironment, + peekWslGitReadEnvironment +} from './wsl-git-read-environment' +import { usesHostGitForWslLinkedWorktree } from './wsl-linked-worktree-git-routing' +import { resolveCommand, type ResolvedCommand } from './command-resolution' + +export type GitExecOptions = { + cwd: string + encoding?: BufferEncoding | 'buffer' + maxBuffer?: number + timeout?: number + stdin?: string + env?: NodeJS.ProcessEnv + signal?: AbortSignal + wslDistro?: string + preferWslDirectGit?: boolean + useConfiguredSshCommandForNetwork?: boolean +} +function wslDistroForCommand(cwd: string | undefined, override?: string): string | null { + if (process.platform !== 'win32') { + return null + } + return (cwd ? parseWslPath(cwd)?.distro : undefined) ?? override ?? null +} + +export function resolveGitCommand( + args: string[], + options: GitExecOptions, + forceLoginShell = false, + captureLoginShellOutput = false +): ResolvedCommand { + if (usesHostGitForWslLinkedWorktree(options.cwd, options.wslDistro)) { + // Why: WSL Git resolves a Windows-authored linked-worktree pointer relative to cwd. + return { binary: 'git', args, cwd: options.cwd, wsl: null, wslMode: null } + } + if (!forceLoginShell && shouldAttemptWslDirectGit(args, options)) { + const distro = wslDistroForCommand(options.cwd, options.wslDistro) + const environment = distro ? peekWslGitReadEnvironment(distro) : undefined + if (environment) { + return resolveCommand('git', args, options.cwd, options.wslDistro, { + wslGitReadEnvironment: environment, + env: options.env + }) + } + if (distro) { + void getWslGitReadEnvironment(distro) + } + } + return resolveGitCommandWithoutProbe(args, options, captureLoginShellOutput) +} + +function shouldAttemptWslDirectGit(args: string[], options: GitExecOptions): boolean { + return Boolean( + process.platform === 'win32' && + // Why either: callers can still opt in explicitly, but a plain read no + // longer has to -- it needs nothing the login shell provides, and routing + // it through one is what exposes callers to the shell's rc output. + (options.preferWslDirectGit || isWslDirectGitReadCommand(args)) && + !options.useConfiguredSshCommandForNetwork && + !Object.entries(options.env ?? {}).some( + ([key, value]) => + key.startsWith('GIT_') && key !== 'GIT_OPTIONAL_LOCKS' && value !== process.env[key] + ) && + options.wslDistro + ) +} + +export function resolveGitCommandWithoutProbe( + args: string[], + options: GitExecOptions, + captureLoginShellOutput = false +): ResolvedCommand { + return resolveCommand('git', args, options.cwd, options.wslDistro, { + useWslLoginShell: Boolean(options.wslDistro), + captureLoginShellOutput + }) +} + +function isDirectWslGitNotFound(error: unknown, resolved: ResolvedCommand): boolean { + if (resolved.wslMode !== 'direct-git' || !error || typeof error !== 'object') { + return false + } + const { code, stderr } = error as { code?: unknown; stderr?: unknown } + const message = typeof stderr === 'string' ? stderr : String(stderr ?? '') + return code === 127 && (message.includes('not found') || message.includes('No such file')) +} + +export function directWslGitExitCode(error: unknown, resolved: ResolvedCommand): number | null { + if (resolved.wslMode !== 'direct-git' || !error || typeof error !== 'object') { + return null + } + const code = (error as { code?: unknown }).code + return typeof code === 'number' ? code : null +} + +export function invalidateMissingDirectWslGit(error: unknown, resolved: ResolvedCommand): boolean { + const isMissing = isDirectWslGitNotFound(error, resolved) + if (isMissing && resolved.wsl) { + invalidateWslGitReadEnvironment(resolved.wsl.distro) + } + return isMissing +} + +export function disableDirectWslGitAfterSuccessfulFallback( + wasMissing: boolean, + resolved: ResolvedCommand +): void { + if (!wasMissing && resolved.wsl) { + disableWslGitReadEnvironment(resolved.wsl.distro) + } +} diff --git a/src/main/git/git-commit-operations.ts b/src/main/git/git-commit-operations.ts new file mode 100644 index 00000000000..ab681318ee3 --- /dev/null +++ b/src/main/git/git-commit-operations.ts @@ -0,0 +1,84 @@ +import type { CommitMessageDraftContext } from '../../shared/commit-message-generation' +import { describeMaxBufferOverflowError, isMaxBufferOverflowError } from './max-buffer-overflow' +import { gitExecFileAsync } from './runner' +import type { GitRuntimeOptions } from './git-runtime-options' +import { gitOptionsForWorktree } from './git-runtime-options' +import { invalidateGitReadCaches } from './git-read-cache' + +const MAX_STAGED_COMMIT_CONTEXT_BYTES = 10 * 1024 * 1024 + +export async function getStagedCommitContext( + worktreePath: string, + options: GitRuntimeOptions = {} +): Promise { + const branchPromise = gitExecFileAsync(['branch', '--show-current'], { + ...gitOptionsForWorktree(worktreePath, options) + }).catch(() => ({ stdout: '' })) + const summaryPromise = gitExecFileAsync(['diff', '--cached', '--name-status'], { + ...gitOptionsForWorktree(worktreePath, options), + maxBuffer: MAX_STAGED_COMMIT_CONTEXT_BYTES + }) + + const [branchResult, summaryResult] = await Promise.all([branchPromise, summaryPromise]) + const stagedSummary = summaryResult.stdout.trim() + if (!stagedSummary) { + return null + } + + let stagedPatch = '' + try { + const patchResult = await gitExecFileAsync( + ['diff', '--cached', '--patch', '--minimal', '--no-color', '--no-ext-diff'], + { + ...gitOptionsForWorktree(worktreePath, options), + maxBuffer: MAX_STAGED_COMMIT_CONTEXT_BYTES + } + ) + stagedPatch = patchResult.stdout + } catch (error) { + if (!isMaxBufferOverflowError(error)) { + throw error + } + // Why: staged patch is optional context (truncated later anyway); degrade to file-name summary rather than fail. + console.warn( + '[git] Staged patch too large to read; using file summary only:', + describeMaxBufferOverflowError(error) + ) + } + + return { + branch: branchResult.stdout.trim() || null, + stagedSummary, + stagedPatch + } +} + +export async function commitChanges( + worktreePath: string, + message: string, + options: GitRuntimeOptions = {} +): Promise<{ success: boolean; error?: string }> { + invalidateGitReadCaches() + try { + await gitExecFileAsync(['commit', '-m', message], gitOptionsForWorktree(worktreePath, options)) + return { success: true } + } catch (error) { + // Why: useful message may be on stderr (hook/GPG failures) or stdout ("nothing to commit"), so try both then message. + const readStringField = (field: string): string | null => { + if (typeof error === 'object' && error && field in error) { + const v = (error as Record)[field] + if (typeof v === 'string' && v.length > 0) { + return v + } + } + return null + } + const errorMessage = + readStringField('stderr') ?? + readStringField('stdout') ?? + (error instanceof Error ? error.message : 'Commit failed') + return { success: false, error: errorMessage } + } finally { + invalidateGitReadCaches() + } +} diff --git a/src/main/git/git-comparison-refs.ts b/src/main/git/git-comparison-refs.ts new file mode 100644 index 00000000000..eeafe7a2816 --- /dev/null +++ b/src/main/git/git-comparison-refs.ts @@ -0,0 +1,53 @@ +import { gitExecFileAsync } from './runner' +import type { GitRuntimeOptions } from './git-runtime-options' +import { gitOptionsForWorktree } from './git-runtime-options' + +export async function resolveCompareRef( + worktreePath: string, + options: GitRuntimeOptions = {} +): Promise { + try { + const { stdout } = await gitExecFileAsync(['branch', '--show-current'], { + ...gitOptionsForWorktree(worktreePath, options) + }) + const branch = stdout.trim() + return branch || 'HEAD' + } catch { + return 'HEAD' + } +} + +export async function resolveRefOid( + worktreePath: string, + ref: string, + options: GitRuntimeOptions = {} +): Promise { + const { stdout } = await gitExecFileAsync(['rev-parse', '--verify', '--end-of-options', ref], { + ...gitOptionsForWorktree(worktreePath, options) + }) + return stdout.trim() +} + +export async function resolveMergeBase( + worktreePath: string, + baseOid: string, + headOid: string, + options: GitRuntimeOptions = {} +): Promise { + const { stdout } = await gitExecFileAsync(['merge-base', baseOid, headOid], { + ...gitOptionsForWorktree(worktreePath, options) + }) + return stdout.trim() +} + +export async function countAheadCommits( + worktreePath: string, + baseOid: string, + headOid: string, + options: GitRuntimeOptions = {} +): Promise { + const { stdout } = await gitExecFileAsync(['rev-list', '--count', `${baseOid}..${headOid}`], { + ...gitOptionsForWorktree(worktreePath, options) + }) + return Number.parseInt(stdout.trim(), 10) || 0 +} diff --git a/src/main/git/git-diff-read-cache.ts b/src/main/git/git-diff-read-cache.ts new file mode 100644 index 00000000000..b4c796549be --- /dev/null +++ b/src/main/git/git-diff-read-cache.ts @@ -0,0 +1,17 @@ +import type { GitDiffResult } from '../../shared/git-diff-compare-types' +import { InFlightPromiseDedupe, stableInFlightKey } from '../../shared/in-flight-promise-dedupe' +import type { GitRuntimeOptions } from './git-runtime-options' + +const gitDiffReadDedupe = new InFlightPromiseDedupe() + +export function clearGitDiffReadCache(): void { + gitDiffReadDedupe.clear() +} + +export function runGitDiffRead( + keyParts: readonly unknown[], + options: GitRuntimeOptions, + load: () => Promise +): Promise { + return gitDiffReadDedupe.run(stableInFlightKey([...keyParts, options.wslDistro ?? null]), load) +} diff --git a/src/main/git/git-discard-changes.ts b/src/main/git/git-discard-changes.ts new file mode 100644 index 00000000000..76adffad76c --- /dev/null +++ b/src/main/git/git-discard-changes.ts @@ -0,0 +1,184 @@ +import * as path from 'node:path' +import { + removeSafeUntrackedDiscardTarget, + removeSafeUntrackedDiscardTargets +} from '../../shared/git-discard-path-safety' +import { gitExecFileAsync } from './runner' +import type { GitRuntimeOptions } from './git-runtime-options' +import { gitOptionsForWorktree } from './git-runtime-options' +import { invalidateGitReadCaches } from './git-read-cache' +import { literalGitPathspec } from './git-literal-pathspec' + +const BULK_CHUNK_SIZE = 100 + +/** + * Discard working tree changes for a file. + */ +export async function discardChanges( + worktreePath: string, + filePath: string, + options: GitRuntimeOptions = {} +): Promise { + invalidateGitReadCaches() + const resolvedWorktree = path.resolve(worktreePath) + const resolvedTarget = path.resolve(worktreePath, filePath) + try { + if (!isWithinWorktree(path, resolvedWorktree, resolvedTarget)) { + throw new Error(`Path "${filePath}" resolves outside the worktree`) + } + + let tracked = false + try { + await gitExecFileAsync( + ['ls-files', '--error-unmatch', '--', literalGitPathspec(filePath, options)], + { + ...gitOptionsForWorktree(worktreePath, options) + } + ) + tracked = true + } catch { + // File is not tracked by git + } + + if (tracked) { + await gitExecFileAsync( + ['restore', '--worktree', '--source=HEAD', '--', literalGitPathspec(filePath, options)], + { + ...gitOptionsForWorktree(worktreePath, options) + } + ) + return + } + + await removeSafeUntrackedDiscardTarget(worktreePath, filePath, (targetPath) => + cleanUntrackedPaths(worktreePath, [targetPath], options) + ) + } finally { + invalidateGitReadCaches() + } +} + +function normalizeGitPathForCompare(filePath: string): string { + return filePath.replace(/\\/g, '/').replace(/\/+$/, '') +} + +function isTrackedPathSpec(filePath: string, trackedPaths: readonly string[]): boolean { + const normalized = normalizeGitPathForCompare(filePath) + return trackedPaths.some((trackedPath) => { + const normalizedTracked = normalizeGitPathForCompare(trackedPath) + return normalizedTracked === normalized || normalizedTracked.startsWith(`${normalized}/`) + }) +} + +async function listTrackedPathSpecs( + worktreePath: string, + filePaths: readonly string[], + options: GitRuntimeOptions = {} +): Promise { + const trackedPaths: string[] = [] + for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) { + const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) + const { stdout } = await gitExecFileAsync( + ['ls-files', '-z', '--', ...chunk.map((filePath) => literalGitPathspec(filePath, options))], + { + ...gitOptionsForWorktree(worktreePath, options) + } + ) + // Why: a tracked directory can hold enough paths to exceed the JS argument limit. + for (const trackedPath of stdout.split('\0')) { + if (trackedPath) { + trackedPaths.push(trackedPath) + } + } + } + return trackedPaths +} + +async function cleanUntrackedPaths( + worktreePath: 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((filePath) => literalGitPathspec(filePath, options))], + { + ...gitOptionsForWorktree(worktreePath, options) + } + ) + } + } +} + +/** + * Discard working tree changes for many paths in a small number of subprocesses. + */ +export async function bulkDiscardChanges( + worktreePath: string, + filePaths: string[], + options: GitRuntimeOptions = {} +): Promise { + invalidateGitReadCaches() + if (filePaths.length === 0) { + return + } + + try { + const resolvedWorktree = path.resolve(worktreePath) + for (const filePath of filePaths) { + const resolvedTarget = path.resolve(worktreePath, filePath) + if (!isWithinWorktree(path, resolvedWorktree, resolvedTarget)) { + throw new Error(`Path "${filePath}" resolves outside the worktree`) + } + } + + const trackedPathSpecs = await listTrackedPathSpecs(worktreePath, filePaths, options) + const trackedPaths = filePaths.filter((filePath) => + isTrackedPathSpec(filePath, trackedPathSpecs) + ) + const untrackedPaths = filePaths.filter( + (filePath) => !isTrackedPathSpec(filePath, trackedPathSpecs) + ) + await removeSafeUntrackedDiscardTargets( + worktreePath, + untrackedPaths, + (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((filePath) => literalGitPathspec(filePath, options)) + ], + { + ...gitOptionsForWorktree(worktreePath, options) + } + ) + } + } + ) + } finally { + invalidateGitReadCaches() + } +} + +export function isWithinWorktree( + pathApi: Pick, + resolvedWorktree: string, + resolvedTarget: string +): boolean { + const relativeTarget = pathApi.relative(resolvedWorktree, resolvedTarget) + return !( + relativeTarget === '' || + relativeTarget === '..' || + relativeTarget.startsWith(`..${pathApi.sep}`) || + pathApi.isAbsolute(relativeTarget) + ) +} diff --git a/src/main/git/git-environment-policy.ts b/src/main/git/git-environment-policy.ts new file mode 100644 index 00000000000..9ef10aa149e --- /dev/null +++ b/src/main/git/git-environment-policy.ts @@ -0,0 +1,232 @@ +import { addWslEnvKeys } from '../wsl-env' +import { gitCredentialPromptGuardEnv } from '../../shared/git-credential-prompt-env' +import { UNTRANSLATED_GIT_OUTPUT_ENV } from '../../shared/git-output-locale' +import { quotePosixShell } from '../../shared/wsl-login-shell-command' +import { DEFAULT_GIT_MAX_BUFFER, execFileCapture } from './exec-file-capture' +import { resolveGitCommand, type GitExecOptions } from './git-command-resolution' + +export function gitOptionalLocksDisabledEnv( + env: NodeJS.ProcessEnv = process.env +): NodeJS.ProcessEnv { + return { + ...env, + GIT_OPTIONAL_LOCKS: '0' + } +} + +/** + * Pin Orca-spawned git to untranslated English output so stderr/progress parsers + * work under any user locale (issue #7808). Terminal git is untouched. + */ +export function untranslatedGitOutputEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { + return { ...env, ...UNTRANSLATED_GIT_OUTPUT_ENV } +} + +export function promptGuardGitEnv( + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform +): NodeJS.ProcessEnv { + return gitCredentialPromptGuardEnv(untranslatedGitOutputEnv(env), platform) +} + +/** + * Credential-prompt guard for a general-purpose shell (PTYs, hook scripts): + * like promptGuardGitEnv but without the issue-7808 locale pins, which would + * change the locale of every child process, not just git's. + */ +export function promptGuardShellEnv( + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform +): NodeJS.ProcessEnv { + return gitCredentialPromptGuardEnv(env, platform) +} + +/** + * Force git non-interactive so it fails fast instead of hanging on a prompt with + * no terminal to answer it; on headless `serve` those stuck calls wedge every + * client (issue #5308). + * + * - GIT_TERMINAL_PROMPT=0: git errors instead of prompting for credentials. + * - GIT_ASKPASS / SSH_ASKPASS: emptied when unset so no GUI helper blocks; a + * caller-provided askpass is preserved (custom setups serve creds non-interactively). + * - GIT_SSH_COMMAND BatchMode=yes: SSH errors instead of prompting (doesn't change + * host trust); only added when the caller hasn't set its own. + */ +export function nonInteractiveGitEnv( + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform +): NodeJS.ProcessEnv { + const next = promptGuardGitEnv(env, platform) + if (!next.GIT_SSH_COMMAND) { + next.GIT_SSH_COMMAND = 'ssh -o BatchMode=yes' + if (platform === 'win32') { + // Why: forward GIT_SSH_COMMAND to WSL only when we set it — a caller's Windows-specific value must not leak into Linux git. + addWslEnvKeys(next, ['GIT_SSH_COMMAND']) + } + } + return next +} + +export type GitSshPolicyMode = + | 'default' + | 'explicit-env' + | 'fallback' + | 'configured-openssh' + | 'configured-wrapper-passthrough' + +const CORE_SSH_COMMAND_PROBE_TIMEOUT_MS = 2500 + +function commandBasename(command: string): string { + const pieces = command.split(/[\\/]+/) + return pieces.at(-1)?.toLowerCase() ?? command.toLowerCase() +} + +function isMergeableOpenSshCommand(command: string): boolean { + const basename = commandBasename(command) + return basename === 'ssh' || basename === 'ssh.exe' +} + +function shellTokenize(command: string): string[] | null { + const tokens: string[] = [] + let current = '' + let quote: "'" | '"' | null = null + let escaped = false + + for (let i = 0; i < command.length; i++) { + const char = command[i] + if (escaped) { + current += char + escaped = false + continue + } + if (char === '\\') { + const next = command[i + 1] + if (next && /[\s'"\\]/.test(next)) { + escaped = true + } else { + current += char + } + continue + } + if (quote) { + if (char === quote) { + quote = null + } else { + current += char + } + continue + } + if (char === "'" || char === '"') { + quote = char + continue + } + if (/\s/.test(char)) { + if (current) { + tokens.push(current) + current = '' + } + continue + } + if (';&|<>()`'.includes(char)) { + return null + } + current += char + } + + if (escaped || quote) { + return null + } + if (current) { + tokens.push(current) + } + return tokens +} + +function shellQuoteToken(token: string): string { + return /^[A-Za-z0-9_@%+=:,./~-]+$/.test(token) ? token : quotePosixShell(token) +} + +function containsShellExpansionSyntax(command: string): boolean { + return command.includes('$') +} + +function withoutBatchModeOptions(tokens: string[]): string[] { + const next: string[] = [] + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i] + const lower = token.toLowerCase() + if (lower === '-o') { + const option = tokens[i + 1]?.toLowerCase() + if (option?.startsWith('batchmode')) { + i += 1 + continue + } + } + if (lower.startsWith('-obatchmode')) { + continue + } + next.push(token) + } + return next +} + +function buildOpenSshBatchModeCommand(configuredCommand: string): string | null { + if (containsShellExpansionSyntax(configuredCommand)) { + return null + } + const tokens = shellTokenize(configuredCommand) + if (!tokens || tokens.length === 0 || !isMergeableOpenSshCommand(tokens[0])) { + return null + } + return [...withoutBatchModeOptions(tokens), '-o', 'BatchMode=yes'].map(shellQuoteToken).join(' ') +} + +export async function buildNetworkSshPolicyEnv(options: GitExecOptions): Promise<{ + env: NodeJS.ProcessEnv + mode: GitSshPolicyMode +}> { + const promptEnv = promptGuardGitEnv(options.env) + if (promptEnv.GIT_SSH_COMMAND) { + return { env: promptEnv, mode: 'explicit-env' } + } + + // Why fenced: a login-shell banner here reads as a user-configured sshCommand, + // which skips the BatchMode fallback below and disarms the no-prompt guard. + const resolved = resolveGitCommand(['config', '--get', 'core.sshCommand'], options, true, true) + let configuredCommand = '' + try { + const { stdout } = await execFileCapture(resolved.binary, resolved.args, { + cwd: resolved.cwd, + encoding: 'utf-8', + maxBuffer: DEFAULT_GIT_MAX_BUFFER, + timeout: CORE_SSH_COMMAND_PROBE_TIMEOUT_MS, + env: promptEnv, + signal: options.signal + }) + const payload = resolved.captured?.readStdout(String(stdout)) ?? String(stdout) + configuredCommand = payload.trim() + } catch { + configuredCommand = '' + } + + if (!configuredCommand) { + const env = { ...promptEnv, GIT_SSH_COMMAND: 'ssh -o BatchMode=yes' } + // Why: WSL routing can come from either an explicit distro or a UNC cwd. + if (resolved.wsl) { + addWslEnvKeys(env, ['GIT_SSH_COMMAND']) + } + return { env, mode: 'fallback' } + } + + const batchModeCommand = buildOpenSshBatchModeCommand(configuredCommand) + if (!batchModeCommand) { + // Why: custom SSH wrappers are user policy; rewriting their argv is riskier than relying on prompt guards + timeout. + return { env: promptEnv, mode: 'configured-wrapper-passthrough' } + } + + const env = { ...promptEnv, GIT_SSH_COMMAND: batchModeCommand } + if (resolved.wsl) { + addWslEnvKeys(env, ['GIT_SSH_COMMAND']) + } + return { env, mode: 'configured-openssh' } +} diff --git a/src/main/git/git-index-mutations.ts b/src/main/git/git-index-mutations.ts new file mode 100644 index 00000000000..5201f40e2b8 --- /dev/null +++ b/src/main/git/git-index-mutations.ts @@ -0,0 +1,101 @@ +import { gitExecFileAsync } from './runner' +import type { GitRuntimeOptions } from './git-runtime-options' +import { gitOptionsForWorktree } from './git-runtime-options' +import { invalidateGitReadCaches } from './git-read-cache' +import { literalGitPathspec } from './git-literal-pathspec' + +const BULK_CHUNK_SIZE = 100 + +/** + * Stage a file. + */ +export async function stageFile( + worktreePath: string, + filePath: string, + options: GitRuntimeOptions = {} +): Promise { + invalidateGitReadCaches() + try { + await gitExecFileAsync( + ['add', '--', literalGitPathspec(filePath, options)], + gitOptionsForWorktree(worktreePath, options) + ) + } finally { + invalidateGitReadCaches() + } +} + +/** + * Unstage a file. + */ +export async function unstageFile( + worktreePath: string, + filePath: string, + options: GitRuntimeOptions = {} +): Promise { + invalidateGitReadCaches() + try { + await gitExecFileAsync(['restore', '--staged', '--', literalGitPathspec(filePath, options)], { + ...gitOptionsForWorktree(worktreePath, options) + }) + } finally { + invalidateGitReadCaches() + } +} + +/** + * Bulk stage files in batches to avoid E2BIG. + */ +export async function bulkStageFiles( + worktreePath: string, + filePaths: string[], + options: GitRuntimeOptions = {} +): Promise { + invalidateGitReadCaches() + if (filePaths.length === 0) { + return + } + try { + 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((filePath) => literalGitPathspec(filePath, options))], + gitOptionsForWorktree(worktreePath, options) + ) + } + } finally { + invalidateGitReadCaches() + } +} + +/** + * Bulk unstage files in batches to avoid E2BIG. + */ +export async function bulkUnstageFiles( + worktreePath: string, + filePaths: string[], + options: GitRuntimeOptions = {} +): Promise { + invalidateGitReadCaches() + if (filePaths.length === 0) { + return + } + try { + 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((filePath) => literalGitPathspec(filePath, options)) + ], + { + ...gitOptionsForWorktree(worktreePath, options) + } + ) + } + } finally { + invalidateGitReadCaches() + } +} diff --git a/src/main/git/git-literal-pathspec.ts b/src/main/git/git-literal-pathspec.ts new file mode 100644 index 00000000000..a7b7a6de9e9 --- /dev/null +++ b/src/main/git/git-literal-pathspec.ts @@ -0,0 +1,7 @@ +import type { GitRuntimeOptions } from './git-runtime-options' + +export function literalGitPathspec(filePath: string, options: GitRuntimeOptions): string { + // Why: Git inside WSL needs POSIX paths, but host paths must stay literal, so convert backslashes only for WSL. + const runtimePath = options.wslDistro ? filePath.replace(/\\/g, '/') : filePath + return `:(literal)${runtimePath}` +} diff --git a/src/main/git/git-process-launch.ts b/src/main/git/git-process-launch.ts new file mode 100644 index 00000000000..cc0f8ddcb16 --- /dev/null +++ b/src/main/git/git-process-launch.ts @@ -0,0 +1,90 @@ +import { + execNodeFileSync as execFileSync, + spawnNodeProcess as spawn, + type SpawnedProcess as ChildProcess, + type SpawnedProcessOptions as SpawnOptions +} from '../../shared/child-process/node-process-execution' +import { recordSubprocessSpawn } from '../diagnostics/main-thread-churn-probe' +import { resolveCommand, prepareWindowsHostGitEnvironment } from './command-resolution' +import { resolveGitCommand } from './git-command-resolution' +import { createAbortError } from './subprocess-lifecycle' +import { untranslatedGitOutputEnv } from './git-environment-policy' + +// Why: sync git blocks the main thread; a dead network drive can hang git for minutes without a timeout (issue #7225's 127s freeze). +const GIT_EXEC_SYNC_TIMEOUT_MS = 15_000 + +/** + * Sync git command execution. Drop-in replacement for + * `execFileSync('git', args, { cwd, encoding, ... })`. + * + * Returns trimmed stdout as a string. + */ +export function gitExecFileSync( + args: string[], + options: { + cwd: string + encoding?: BufferEncoding + stdio?: SpawnOptions['stdio'] + timeout?: number + } +): string { + const resolved = resolveCommand('git', args, options.cwd) + const spawnStartedAt = performance.now() + try { + return execFileSync(resolved.binary, resolved.args, { + cwd: resolved.cwd, + encoding: options.encoding ?? 'utf-8', + env: untranslatedGitOutputEnv(), + stdio: options.stdio ?? ['pipe', 'pipe', 'pipe'], + timeout: options.timeout ?? GIT_EXEC_SYNC_TIMEOUT_MS, + windowsHide: true + }) as string + } finally { + // Sync exec blocks the main thread for its whole duration — the cost issue #7576 flags. + recordSubprocessSpawn(resolved.binary, resolved.args, performance.now() - spawnStartedAt) + } +} + +/** + * Spawn a git child process. Drop-in replacement for + * `spawn('git', args, { cwd, stdio, ... })`. + */ +type GitSpawnOptions = SpawnOptions & { cwd: string; wslDistro?: string } + +export async function gitSpawnAfterWindowsEnvironmentReady( + args: string[], + options: GitSpawnOptions +): Promise { + if (options.signal?.aborted) { + throw createAbortError() + } + const resolved = resolveGitCommand(args, { + cwd: options.cwd, + ...(options.wslDistro ? { wslDistro: options.wslDistro } : {}), + ...(options.env ? { env: options.env } : {}) + }) + const env = await (prepareWindowsHostGitEnvironment(resolved, options.env, options.signal) ?? + options.env) + if (options.signal?.aborted) { + throw createAbortError() + } + return gitSpawn(args, env === options.env ? options : { ...options, env }) +} + +export function gitSpawn(args: string[], options: GitSpawnOptions): ChildProcess { + const { wslDistro, ...spawnOptions } = options + const resolved = resolveGitCommand(args, { + cwd: options.cwd, + ...(wslDistro ? { wslDistro } : {}), + ...(spawnOptions.env ? { env: spawnOptions.env } : {}) + }) + const spawnStartedAt = performance.now() + const child = spawn(resolved.binary, resolved.args, { + ...spawnOptions, + env: untranslatedGitOutputEnv(spawnOptions.env ?? process.env), + windowsHide: true, + cwd: resolved.cwd + }) + recordSubprocessSpawn(resolved.binary, resolved.args, performance.now() - spawnStartedAt) + return child +} diff --git a/src/main/git/git-read-cache.ts b/src/main/git/git-read-cache.ts new file mode 100644 index 00000000000..2a274cfd911 --- /dev/null +++ b/src/main/git/git-read-cache.ts @@ -0,0 +1,47 @@ +import type { GitStatusResult } from '../../shared/git-status-types' +import { clearGitStatusLineStatsCache } from '../../shared/git-status-line-stats-cache' +import { invalidateGitBranchLineTotalInFlight } from '../../shared/git-branch-line-total' +import { GitStatusReadLeaseOwner } from './git-status-read-lease-owner' +import { invalidateGitUpstreamStatusReads } from './upstream' +import { clearGitDiffReadCache } from './git-diff-read-cache' +import { clearSubmodulePathsCacheForTests } from './submodule-paths' +import { + clearEffectiveUpstreamStatusCaches, + clearResolvedUpstreamNameCache +} from './effective-upstream-status-cache' + +const statusReadLeaseOwner = new GitStatusReadLeaseOwner() + +export function leaseGitStatusRead( + cacheKey: string, + signal: AbortSignal | undefined, + read: (signal: AbortSignal) => Promise +): Promise { + return statusReadLeaseOwner.lease(cacheKey, signal, read) +} + +// Why: clearing only some in-flight reads would let a post-mutation status join +// a pre-mutation read and publish it as current. +export function invalidateGitReadCaches(): void { + clearGitDiffReadCache() + statusReadLeaseOwner.invalidate() + invalidateGitBranchLineTotalInFlight() + invalidateGitUpstreamStatusReads() + clearGitStatusLineStatsCache() + clearSubmodulePathsCacheForTests() + clearResolvedUpstreamNameCache() +} +export function clearEffectiveUpstreamStatusCacheForTests(): void { + clearEffectiveUpstreamStatusCaches() + invalidateGitReadCaches() +} + +export async function runWithGitReadCacheInvalidation(run: () => Promise): Promise { + invalidateGitReadCaches() + try { + return await run() + } finally { + // Why: a read that started mid-mutation can be stale too, so invalidate again after. + invalidateGitReadCaches() + } +} diff --git a/src/main/git/git-stream.ts b/src/main/git/git-stream.ts new file mode 100644 index 00000000000..a57816bbe0e --- /dev/null +++ b/src/main/git/git-stream.ts @@ -0,0 +1,223 @@ +import { + spawnNodeProcess as spawn, + type SpawnedProcess as ChildProcess, + type SpawnedProcessOptions as SpawnOptions +} from '../../shared/child-process/node-process-execution' +import { StringDecoder } from 'node:string_decoder' +import { withGitSpan } from '../observability/instrumentation' +import { recordSubprocessSpawn } from '../diagnostics/main-thread-churn-probe' +import { + isWslLinkedWorktreeGitRoutingCandidate, + prepareWslLinkedWorktreeGitRouting +} from './wsl-linked-worktree-git-routing' +import { + directWslGitExitCode, + disableDirectWslGitAfterSuccessfulFallback, + invalidateMissingDirectWslGit, + resolveGitCommand, + resolveGitCommandWithoutProbe, + type GitExecOptions +} from './git-command-resolution' +import { DEFAULT_GIT_MAX_BUFFER } from './exec-file-capture' +import { createAbortError, killSpawnedCommandTree } from './subprocess-lifecycle' +import { nonInteractiveGitEnv, untranslatedGitOutputEnv } from './git-environment-policy' +import { gitSpawn } from './git-process-launch' +import { prepareWindowsHostGitEnvironment, type ResolvedCommand } from './command-resolution' + +/** Result of a streamed git command; `stoppedEarly` is true when onStdout asked to stop before the child exited. */ +export type GitStreamResult = { stoppedEarly: boolean } + +type GitStreamOptions = { + cwd: string + env?: NodeJS.ProcessEnv + wslDistro?: string + preferWslDirectGit?: boolean + signal?: AbortSignal + /** Byte backstop; defaults to DEFAULT_GIT_MAX_BUFFER. */ + maxBuffer?: number + /** + * Called for each decoded stdout chunk. Return true to stop: the child is + * killed and the promise resolves with stoppedEarly=true. + */ + onStdout: (chunk: string) => boolean | void +} + +/** + * Stream a git command's stdout incrementally instead of buffering it whole. + * + * Why: output larger than V8's max string (e.g. status on a repo with a huge + * un-ignored folder) crashes the process when buffered; streaming keeps memory + * bounded and lets the parser stop git early. Built on gitSpawn for WSL routing. + */ +export async function gitStreamStdout( + args: string[], + options: GitStreamOptions +): Promise { + const maxBuffer = options.maxBuffer ?? DEFAULT_GIT_MAX_BUFFER + return withGitSpan({ args, cwd: options.cwd }, async () => { + if (isWslLinkedWorktreeGitRoutingCandidate(options.cwd, options.wslDistro)) { + await prepareWslLinkedWorktreeGitRouting(options.cwd, options.wslDistro, { + signal: options.signal + }) + } + const gitOptions: GitExecOptions = { + cwd: options.cwd, + ...(options.env ? { env: options.env } : {}), + ...(options.wslDistro ? { wslDistro: options.wslDistro } : {}), + ...(options.preferWslDirectGit ? { preferWslDirectGit: true } : {}), + ...(options.signal ? { signal: options.signal } : {}) + } + let resolved = resolveGitCommand(args, gitOptions) + const environmentReady = prepareWindowsHostGitEnvironment( + resolved, + gitOptions.env, + options.signal + ) + if (environmentReady) { + gitOptions.env = await environmentReady + } + resolved = resolveGitCommand(args, gitOptions) + const stream = (command: ResolvedCommand): Promise => + new Promise((resolve, reject) => { + if (options.signal?.aborted) { + reject(createAbortError()) + return + } + const stdio: SpawnOptions['stdio'] = ['ignore', 'pipe', 'pipe'] + const spawnOptions = { + cwd: options.cwd, + env: nonInteractiveGitEnv(gitOptions.env), + stdio, + wslDistro: options.wslDistro, + windowsHide: true + } + let child: ChildProcess + if (command.wslMode === 'direct-git') { + const spawnStartedAt = performance.now() + child = spawn(command.binary, command.args, { + cwd: command.cwd, + env: untranslatedGitOutputEnv(spawnOptions.env), + stdio: spawnOptions.stdio, + windowsHide: true + }) + recordSubprocessSpawn(command.binary, command.args, performance.now() - spawnStartedAt) + } else { + child = gitSpawn(args, spawnOptions) + } + + let settled = false + let stoppedEarly = false + let stdoutBytes = 0 + let stderr = '' + let stderrBytes = 0 + // Why: decode statefully so a multibyte UTF-8 char split across chunks isn't corrupted into replacement chars. + const stdoutDecoder = new StringDecoder('utf8') + const stderrDecoder = new StringDecoder('utf8') + + const cleanup = (): void => { + child.stdout?.off('data', onStdoutData) + child.stderr?.off('data', onStderrData) + child.off('error', onError) + child.off('close', onClose) + options.signal?.removeEventListener('abort', onAbort) + // Flush any bytes the decoders were holding for an incomplete sequence. + stdoutDecoder.end() + stderrDecoder.end() + } + const finish = (error: Error | null): void => { + if (settled) { + return + } + settled = true + cleanup() + if (error) { + reject(Object.assign(error, { stderr, stdoutBytes })) + return + } + resolve({ stoppedEarly }) + } + + function onStdoutData(chunk: Buffer): void { + stdoutBytes += chunk.byteLength + if (stdoutBytes > maxBuffer) { + void killSpawnedCommandTree(child) + finish(new Error('git stdout exceeded maxBuffer.')) + return + } + const decoded = stdoutDecoder.write(chunk) + if (decoded.length === 0) { + return + } + // Why: a throw from the caller's parser would escape this event handler and crash main; convert to a rejection. + let shouldStop: boolean | void + try { + shouldStop = options.onStdout(decoded) + } catch (error) { + void killSpawnedCommandTree(child) + finish(error instanceof Error ? error : new Error(String(error))) + return + } + if (shouldStop === true) { + // Parser hit its limit: kill git and resolve cleanly with the partial output. + stoppedEarly = true + void killSpawnedCommandTree(child) + finish(null) + } + } + function onStderrData(chunk: Buffer): void { + stderrBytes += chunk.byteLength + if (stderrBytes > maxBuffer) { + void killSpawnedCommandTree(child) + finish(new Error('git stderr exceeded maxBuffer.')) + return + } + stderr += stderrDecoder.write(chunk) + } + function onError(error: Error): void { + finish(error) + } + function onClose(code: number | null): void { + if (stoppedEarly || code === 0) { + finish(null) + return + } + finish(Object.assign(new Error(`git exited with ${code}: ${stderr}`), { code })) + } + function onAbort(): void { + if (!child.pid) { + // Why: failed spawn reports ENOENT after abort cleanup; retain a listener so it cannot crash main. + child.once('error', () => {}) + } + void killSpawnedCommandTree(child) + finish(createAbortError()) + } + + child.stdout?.on('data', onStdoutData) + child.stderr?.on('data', onStderrData) + child.on('error', onError) + child.on('close', onClose) + options.signal?.addEventListener('abort', onAbort, { once: true }) + if (options.signal?.aborted) { + onAbort() + } + }) + try { + return await stream(resolved) + } catch (error) { + const stdoutBytes = + error && typeof error === 'object' ? (error as { stdoutBytes?: unknown }).stdoutBytes : null + if ( + stdoutBytes === 0 && + directWslGitExitCode(error, resolved) !== null && + !options.signal?.aborted + ) { + const wasMissing = invalidateMissingDirectWslGit(error, resolved) + resolved = resolveGitCommandWithoutProbe(args, gitOptions) + const result = await stream(resolved) + disableDirectWslGitAfterSuccessfulFallback(wasMissing, resolved) + return result + } + throw error + } + }) +} diff --git a/src/main/git/git-username.ts b/src/main/git/git-username.ts index da91361d0b8..8523b344475 100644 --- a/src/main/git/git-username.ts +++ b/src/main/git/git-username.ts @@ -1,7 +1,9 @@ import type { SshGitProvider } from '../providers/ssh-git-provider' -import { extractExecError, ghExecFileAsync, gitExecFileAsync } from './runner' +import { gitExecFileAsync } from './runner' +import { ghExecFileAsync } from './github-cli-runner' +import { extractExecError } from './exec-error' import { parseHostedRemote } from './hosted-remote-url' -import { resolveDefaultBaseRefViaExec } from './repo' +import { resolveDefaultBaseRefViaExec } from './default-base-ref' const EXPLICIT_USERNAME_CONFIG_KEYS = ['github.user', 'user.username'] as const diff --git a/src/main/git/github-cli-policy.ts b/src/main/git/github-cli-policy.ts new file mode 100644 index 00000000000..166cd14bd4b --- /dev/null +++ b/src/main/git/github-cli-policy.ts @@ -0,0 +1,297 @@ +import { + createGhRateLimitBlockedError, + getGhRateLimitBlockedUntilMs, + ghRateLimitScopeKey, + type GhRateLimitBucket +} from './gh-rate-limit-breaker' +import { parseRetryAfterMs } from './exec-error' +import type { ResolvedCommand } from './command-resolution' +import type { GitExecOptions } from './git-command-resolution' +import { createAbortError } from './subprocess-lifecycle' + +// `cwd?` omitted for non-repo-scoped gh calls (rate_limit, listAccessibleProjects) so one WSL-aware wrapper serves both. +// `wslDistro?` routes global cwd-less gh through `wsl.exe -d ` on WSL-only Windows where gh.exe isn't on host PATH. +// `idempotent?` gates transient-error retry (auto-detected from argv); retrying a write that already reached GitHub would duplicate it. +export type GhExecOptions = Omit & { + cwd?: string + wslDistro?: string + idempotent?: boolean + // Why: `gh api` and `--repo OWNER/REPO` shorthand resolve against gh's + // default host, not the repo's remote. Carrying the host here lets the + // runner qualify every spawn once, so call sites can't silently fall back + // to github.com for GHES repos; it also scopes the rate-limit breaker. + host?: string +} + +const NON_IDEMPOTENT_METHODS = new Set(['POST', 'PATCH', 'PUT', 'DELETE']) +// `gh ` write subcommands; reads are absent on purpose so they keep retrying. +const NON_IDEMPOTENT_GH_VERBS = new Set([ + 'create', + 'edit', + 'update', + 'delete', + 'close', + 'reopen', + 'merge', + 'comment', + 'review', + 'ready', + 'lock', + 'unlock', + 'pin', + 'unpin', + 'transfer', + 'develop' +]) + +export function argsLookIdempotent(args: string[]): boolean { + let explicitMethodSeen = false + let hasApiBodyField = false + let hasGraphQlQuery = false + const isGraphQlApi = args[0] === 'api' && args[1] === 'graphql' + for (let i = 0; i < args.length; i++) { + const a = args[i] + if (a === '-X' || a === '--method') { + explicitMethodSeen = true + const next = args[i + 1] + if (typeof next === 'string' && NON_IDEMPOTENT_METHODS.has(next.toUpperCase())) { + return false + } + } + // Single-token form `--method=POST` (gh accepts this). + if (a.startsWith('--method=')) { + explicitMethodSeen = true + const value = a.slice('--method='.length) + if (NON_IDEMPOTENT_METHODS.has(value.toUpperCase())) { + return false + } + } + // `gh api` auto-POSTs when -f/-F/--field body fields are given without -X; track them. + if (a === '-f' || a === '-F' || a === '--field' || a === '--raw-field') { + hasApiBodyField = true + } else if ( + a.startsWith('-f=') || + a.startsWith('-F=') || + a.startsWith('--field=') || + a.startsWith('--raw-field=') + ) { + hasApiBodyField = true + } + // Detect GraphQL `query=mutation(…)` so endpoint writes also fail fast on transient errors. + if (a.startsWith('query=')) { + hasGraphQlQuery = true + const trimmed = a.slice('query='.length).trimStart().toLowerCase() + if (trimmed.startsWith('mutation')) { + return false + } + } + } + // `gh api -f foo=bar` with no -X auto-POSTs → non-idempotent; GraphQL query bodies are the exception (still reads). + if ( + args[0] === 'api' && + hasApiBodyField && + !explicitMethodSeen && + !(isGraphQlApi && hasGraphQlQuery) + ) { + return false + } + // `gh ` writes (args[1]); `gh api` without -X defaults to idempotent GET, so it's excluded here. + if (args.length >= 2 && args[0] !== 'api') { + if (NON_IDEMPOTENT_GH_VERBS.has(args[1])) { + return false + } + } + return true +} + +/** + * Classify whether a gh execFile rejection is worth retrying. + * + * Why: gh surfaces HTTP status as stderr substrings ("HTTP 504", econnreset, …). + * Retry 5xx/network resets and 429 only without Retry-After (propagate those so + * the UI can show the wait); primary-rate-limit 403 is never transient. + */ +export function isTransientGhError(stderr: string): boolean { + const s = stderr.toLowerCase() + if ( + s.includes('http 500') || + s.includes('http 502') || + s.includes('http 503') || + s.includes('http 504') || + s.includes('econnreset') || + s.includes('etimedout') || + s.includes('socket hang up') + ) { + return true + } + // 429 without Retry-After: retry. With Retry-After: propagate. + if (s.includes('http 429')) { + return parseRetryAfterMs(stderr) === null + } + return false +} + +// Why: 3 attempts total (250ms → 1s backoff); array length defines retry count (total attempts = length + 1). +export const GH_RETRY_DELAYS_MS = [250, 1000] as const + +// Why: Retry-After is unbounded and untrusted; cap at 30s so a gh call can't block the IPC thread indefinitely. +export const GH_RETRY_AFTER_MAX_MS = 30_000 +const DEFAULT_GH_EXEC_TIMEOUT_MS = 30_000 + +export async function sleep(ms: number, signal?: AbortSignal): Promise { + if (signal?.aborted) { + throw createAbortError() + } + await new Promise((resolve, reject) => { + const timer = setTimeout(finish, ms) + const onAbort = (): void => finish(createAbortError()) + function finish(error?: Error): void { + clearTimeout(timer) + signal?.removeEventListener('abort', onAbort) + if (error) { + reject(error) + } else { + resolve() + } + } + signal?.addEventListener('abort', onAbort, { once: true }) + }) +} + +export function defaultGhExecTimeoutMs(env: NodeJS.ProcessEnv = process.env): number { + const raw = env.ORCA_GH_EXEC_TIMEOUT_MS + if (!raw) { + return DEFAULT_GH_EXEC_TIMEOUT_MS + } + const parsed = Number(raw) + return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_GH_EXEC_TIMEOUT_MS +} + +export function nonInteractiveGhEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { + return { + ...env, + GH_PROMPT_DISABLED: env.GH_PROMPT_DISABLED ?? '1' + } +} + +function hasGhHostnameFlag(args: readonly string[]): boolean { + return args.some((arg) => arg === '--hostname' || arg.startsWith('--hostname=')) +} + +function hostQualifiedGhRepoValue(value: string, host: string): string { + // URLs and already-qualified HOST/OWNER/REPO values pass through untouched. + if (value.includes('://') || value.split('/').length !== 2) { + return value + } + return `${host}/${value}` +} + +/** + * Host-qualify a gh invocation from `options.host`: `--hostname` for `api` + * calls, `HOST/OWNER/REPO` for `--repo`/`-R` shorthand. SSH-backed repos run + * gh with no cwd, so this is their only host signal (#8312). + * + * @internal exported for tests. + */ +export function applyGhHostToArgs(args: string[], host?: string): string[] { + if (!host) { + return args + } + let result = args + if (result[0] === 'api' && !hasGhHostnameFlag(result)) { + result = ['api', '--hostname', host, ...result.slice(1)] + } + // Why: bare OWNER/REPO shorthand resolves against gh's default host — GH_HOST + // when set — so github.com must be qualified too, not just GHES, or a + // process-level GH_HOST redirects pinned github.com commands. + // Combined short forms (`-Ra/b`, `-R=a/b`) are deliberately not rewritten: + // no call site uses them, and prefix-matching `-R` corrupts free-text values + // of other flags (e.g. a --title that happens to start with `-R`). + const qualified: string[] = [] + for (let i = 0; i < result.length; i += 1) { + const arg = result[i] + if (arg === '--repo' || arg === '-R') { + qualified.push(arg) + const value = result[i + 1] + if (value !== undefined) { + qualified.push(hostQualifiedGhRepoValue(value, host)) + i += 1 + } + continue + } + if (arg.startsWith('--repo=')) { + qualified.push(`--repo=${hostQualifiedGhRepoValue(arg.slice('--repo='.length), host)}`) + continue + } + qualified.push(arg) + } + return qualified +} + +function explicitGhHostname(args: readonly string[]): string | undefined { + for (let i = 0; i < args.length; i += 1) { + if (args[i] === '--hostname') { + const value = args[i + 1]?.trim() + return value || undefined + } + if (args[i].startsWith('--hostname=')) { + const value = args[i].slice('--hostname='.length).trim() + return value || undefined + } + } + return undefined +} + +function explicitGhRepoHostname(args: readonly string[]): string | undefined { + for (let i = 0; i < args.length; i += 1) { + let value: string | undefined + if (args[i] === '--repo' || args[i] === '-R') { + value = args[i + 1] + } else if (args[i].startsWith('--repo=')) { + value = args[i].slice('--repo='.length) + } + const parts = value?.trim().split('/') + if (parts?.length === 3 && parts.every(Boolean)) { + return parts[0] + } + } + return undefined +} + +export function ghRateLimitScope( + args: readonly string[], + options: GhExecOptions, + resolved: ResolvedCommand +): string { + const runtime = resolved.wsl ? `wsl:${resolved.wsl.distro.toLowerCase()}` : 'native' + // Why: an explicit argv hostname controls the actual gh request even when + // GH_HOST or options.host disagree, so breaker state must follow that host. + const host = + explicitGhHostname(args) ?? + options.host ?? + explicitGhRepoHostname(args) ?? + options.env?.GH_HOST ?? + process.env.GH_HOST ?? + 'github.com' + return ghRateLimitScopeKey(runtime, host) +} + +export function assertGhRateLimitScopeAvailable( + args: readonly string[], + options: GhExecOptions, + resolved: ResolvedCommand, + bucket: GhRateLimitBucket, + exemptProbe: boolean +): void { + if (exemptProbe) { + return + } + const blockedUntilMs = getGhRateLimitBlockedUntilMs( + bucket, + Date.now(), + ghRateLimitScope(args, options, resolved) + ) + if (blockedUntilMs !== null) { + throw createGhRateLimitBlockedError(bucket, blockedUntilMs) + } +} diff --git a/src/main/git/github-cli-runner.ts b/src/main/git/github-cli-runner.ts new file mode 100644 index 00000000000..7d639d288ab --- /dev/null +++ b/src/main/git/github-cli-runner.ts @@ -0,0 +1,112 @@ +import { + classifyGhRateLimitBucket, + isGhPrimaryRateLimitStderr, + isGhRateLimitProbe, + notifyGhPrimaryRateLimit +} from './gh-rate-limit-breaker' +import { extractExecError, parseRetryAfterMs } from './exec-error' +import { + canFallBackToHostGitHubCli, + isHostCommandMissing, + resolveCommand, + resolveDefaultWslCli, + resolveHostGitHubCli +} from './command-resolution' +import { execFileCapture } from './exec-file-capture' +import { + applyGhHostToArgs, + argsLookIdempotent, + assertGhRateLimitScopeAvailable, + defaultGhExecTimeoutMs, + ghRateLimitScope, + GH_RETRY_AFTER_MAX_MS, + GH_RETRY_DELAYS_MS, + isTransientGhError, + nonInteractiveGhEnv, + sleep, + type GhExecOptions +} from './github-cli-policy' + +/** + * Async gh CLI execution. Drop-in replacement for + * `execFileAsync('gh', args, { cwd, encoding, ... })`. + * + * Retries transient 5xx / 429-without-Retry-After / network-reset failures with + * exponential backoff; other errors fail fast. + */ +export async function ghExecFileAsync( + args: string[], + options: GhExecOptions = {} +): Promise<{ stdout: string; stderr: string }> { + // Why: retry safety must reflect the original call even when fallbacks replace the resolved command. + const idempotent = options.idempotent ?? argsLookIdempotent(args) + args = applyGhHostToArgs(args, options.host) + let resolved = resolveCommand('gh', args, options.cwd, options.wslDistro) + // Why: while a bucket is rate-limited every spawn returns 403 — fail fast; the probe is exempt so the breaker can learn the reset. + // Why: scope by runtime and host so unrelated github.com, GHES, and WSL quotas cannot block each other. + const rateLimitBucket = classifyGhRateLimitBucket(args) + const rateLimitProbe = isGhRateLimitProbe(args) + assertGhRateLimitScopeAvailable(args, options, resolved, rateLimitBucket, rateLimitProbe) + let lastError: unknown + let attemptedHostFallback = false + let attemptedDefaultWslFallback = false + for (let attempt = 0; attempt <= GH_RETRY_DELAYS_MS.length; attempt++) { + try { + const { stdout, stderr } = await execFileCapture(resolved.binary, resolved.args, { + cwd: resolved.cwd, + encoding: (options.encoding ?? 'utf-8') as BufferEncoding, + maxBuffer: options.maxBuffer, + // Why: bound gh so one stuck child fails visibly instead of wedging the IPC lane. + timeout: options.timeout ?? defaultGhExecTimeoutMs(options.env), + env: nonInteractiveGhEnv(options.env), + signal: options.signal + }) + return { stdout: stdout as string, stderr: stderr as string } + } catch (err) { + lastError = err + const { stderr } = extractExecError(err) + if (isGhPrimaryRateLimitStderr(stderr)) { + notifyGhPrimaryRateLimit(rateLimitBucket, ghRateLimitScope(args, options, resolved)) + } + if ( + process.platform === 'win32' && + !attemptedDefaultWslFallback && + resolved.wsl === null && + !options.cwd && + !options.wslDistro && + isHostCommandMissing(err, 'gh') + ) { + const wslResolved = resolveDefaultWslCli('gh', args) + if (wslResolved) { + // Why: WSL-only Windows installs have no host gh.exe, and global calls (rate_limit/auth) carry no cwd to route by. + resolved = wslResolved + attemptedDefaultWslFallback = true + assertGhRateLimitScopeAvailable(args, options, resolved, rateLimitBucket, rateLimitProbe) + attempt = -1 + continue + } + } + if (!attemptedHostFallback && canFallBackToHostGitHubCli('gh', args, resolved, stderr)) { + resolved = resolveHostGitHubCli('gh', args) + attemptedHostFallback = true + assertGhRateLimitScopeAvailable(args, options, resolved, rateLimitBucket, rateLimitProbe) + attempt = -1 + continue + } + const isLastAttempt = attempt >= GH_RETRY_DELAYS_MS.length + if (idempotent && !isLastAttempt && isTransientGhError(stderr)) { + // Why: honor the server's Retry-After over our backoff (a shorter sleep just re-fails); cap so a huge hint can't stall IPC. + const retryAfterMs = parseRetryAfterMs(stderr) + const delayMs = + retryAfterMs !== null + ? Math.min(retryAfterMs, GH_RETRY_AFTER_MAX_MS) + : GH_RETRY_DELAYS_MS[attempt] + await sleep(delayMs, options.signal) + continue + } + throw err + } + } + // Unreachable: the loop either returns or throws. Here for TS exhaustiveness. + throw lastError +} diff --git a/src/main/git/gitlab-cli-runner.ts b/src/main/git/gitlab-cli-runner.ts new file mode 100644 index 00000000000..78e17a8ea9b --- /dev/null +++ b/src/main/git/gitlab-cli-runner.ts @@ -0,0 +1,111 @@ +import { addWslEnvKeys } from '../wsl-env' +import { extractExecError, parseRetryAfterMs } from './exec-error' +import { isHostCommandMissing, resolveCommand, resolveDefaultWslCli } from './command-resolution' +import { execFileCapture } from './exec-file-capture' +import { + argsLookIdempotent, + GH_RETRY_AFTER_MAX_MS, + GH_RETRY_DELAYS_MS, + isTransientGhError, + sleep +} from './github-cli-policy' +import type { GitExecOptions } from './git-command-resolution' + +// ─── glab CLI runner ──────────────────────────────────────────────── +// Why: cloned from the gh runner rather than abstracted behind a generic runner, to avoid touching the working gh path. + +type GlabExecOptions = Omit & { + cwd?: string + wslDistro?: string + idempotent?: boolean + allowDefaultWslFallback?: boolean +} + +/** Async glab CLI execution; drop-in for execFileAsync('glab', …). Retry policy mirrors ghExecFileAsync. */ +/** + * glab's `--hostname` rejects host:port, so a ported self-hosted GitLab must use the GITLAB_HOST env var instead — translate it. + * @internal exported for tests. + */ +export function redirectPortedHostnameToEnv( + args: string[], + options: GlabExecOptions +): { args: string[]; options: GlabExecOptions } { + const i = args.indexOf('--hostname') + if (i === -1 || i + 1 >= args.length) { + return { args, options } + } + const host = args[i + 1] + if (!/^[^/\s]+:\d+$/.test(host)) { + return { args, options } + } + // Why WSLENV: a glab routed into a distro only sees Windows-side variables + // named in WSLENV, so without this the ported host silently never crosses and + // glab talks to gitlab.com instead (#12557). Credit: #12558. + const env: NodeJS.ProcessEnv = { ...(options.env ?? process.env), GITLAB_HOST: host } + // Unguarded by platform on purpose: WSLENV is meaningless outside Windows, so + // the extra key is inert there, and gating it would need the test to know the + // platform for no behavioural gain. + addWslEnvKeys(env, ['GITLAB_HOST']) + return { + args: [...args.slice(0, i), ...args.slice(i + 2)], + options: { ...options, env } + } +} + +export async function glabExecFileAsync( + args: string[], + options: GlabExecOptions = {} +): Promise<{ stdout: string; stderr: string }> { + ;({ args, options } = redirectPortedHostnameToEnv(args, options)) + let resolved = resolveCommand('glab', args, options.cwd, options.wslDistro) + let lastError: unknown + let attemptedDefaultWslFallback = false + for (let attempt = 0; attempt <= GH_RETRY_DELAYS_MS.length; attempt++) { + try { + const { stdout, stderr } = await execFileCapture(resolved.binary, resolved.args, { + cwd: resolved.cwd, + encoding: (options.encoding ?? 'utf-8') as BufferEncoding, + maxBuffer: options.maxBuffer, + timeout: options.timeout, + env: options.env, + signal: options.signal + }) + return { stdout: stdout as string, stderr: stderr as string } + } catch (err) { + lastError = err + const { stderr } = extractExecError(err) + if ( + process.platform === 'win32' && + !attemptedDefaultWslFallback && + resolved.wsl === null && + !options.cwd && + !options.wslDistro && + options.allowDefaultWslFallback !== false && + isHostCommandMissing(err, 'glab') + ) { + const wslResolved = resolveDefaultWslCli('glab', args) + if (wslResolved) { + // Why: mirror gh's WSL-only fallback for global GitLab project/auth calls. + resolved = wslResolved + attemptedDefaultWslFallback = true + attempt = -1 + continue + } + } + const isLastAttempt = attempt >= GH_RETRY_DELAYS_MS.length + // Why: mirror gh's write-safety gate — don't auto-retry a non-idempotent write that GitLab may already have applied. + const idempotent = options.idempotent ?? argsLookIdempotent(args) + if (idempotent && !isLastAttempt && isTransientGhError(stderr)) { + const retryAfterMs = parseRetryAfterMs(stderr) + const delayMs = + retryAfterMs !== null + ? Math.min(retryAfterMs, GH_RETRY_AFTER_MAX_MS) + : GH_RETRY_DELAYS_MS[attempt] + await sleep(delayMs) + continue + } + throw err + } + } + throw lastError +} diff --git a/src/main/git/remote-drift.ts b/src/main/git/remote-drift.ts new file mode 100644 index 00000000000..5e6fd701574 --- /dev/null +++ b/src/main/git/remote-drift.ts @@ -0,0 +1,52 @@ +import { parseGitRevListAheadBehindCounts } from '../../shared/git-rev-list-output' +import { gitExecFileAsync } from './runner' +import { repositoryGitExecOptions, type LocalGitExecOptions } from './repository-git-execution' + +const REMOTE_DRIFT_PROBE_TIMEOUT_MS = 15_000 + +/** Return the merge-base-symmetric delta for localRef vs remoteRef, or null on failure. */ +export async function getRemoteDrift( + repoPath: string, + localRef: string, + remoteRef: string, + options: LocalGitExecOptions = {} +): Promise<{ ahead: number; behind: number } | null> { + try { + const { stdout } = await gitExecFileAsync( + ['rev-list', '--left-right', '--count', `${localRef}...${remoteRef}`], + { + ...repositoryGitExecOptions(repoPath, options), + timeout: REMOTE_DRIFT_PROBE_TIMEOUT_MS + } + ) + const counts = parseGitRevListAheadBehindCounts(stdout) + if (counts.status !== 'ok') { + return null + } + return { ahead: counts.ahead, behind: counts.behind } + } catch { + return null + } +} + +/** Up to `limit` commit subjects on remoteRef but not localRef, recency order; [] on git failure. */ +export async function getRecentDriftSubjects( + repoPath: string, + localRef: string, + remoteRef: string, + limit: number, + options: LocalGitExecOptions = {} +): Promise { + try { + const { stdout } = await gitExecFileAsync( + ['log', '--format=%s', '-n', String(limit), `${localRef}..${remoteRef}`], + { + ...repositoryGitExecOptions(repoPath, options), + timeout: REMOTE_DRIFT_PROBE_TIMEOUT_MS + } + ) + return stdout.split('\n').filter((subject) => subject.trim().length > 0) + } catch { + return [] + } +} diff --git a/src/main/git/remote.ts b/src/main/git/remote.ts index 687102b1184..a2915c01915 100644 --- a/src/main/git/remote.ts +++ b/src/main/git/remote.ts @@ -10,7 +10,7 @@ import type { GitRuntimeOptions } from './git-runtime-options' import { gitOptionsForWorktree } from './git-runtime-options' import { validateGitPushTarget } from './push-target-validation' import { gitExecFileAsync } from './runner' -import { runWithGitReadCacheInvalidation } from './status' +import { runWithGitReadCacheInvalidation } from './git-read-cache' async function getConfiguredPushTarget( worktreePath: string, diff --git a/src/main/git/remove-worktree-branch-cleanup.test.ts b/src/main/git/remove-worktree-branch-cleanup.test.ts index 6c4885bca75..5bf28cc671b 100644 --- a/src/main/git/remove-worktree-branch-cleanup.test.ts +++ b/src/main/git/remove-worktree-branch-cleanup.test.ts @@ -34,9 +34,10 @@ vi.mock('./runner', () => ({ gitExecFileSync: gitExecFileSyncMock, translateWslOutputPaths: translateWslOutputPathsMock })) +vi.mock('./git-process-launch', () => ({ gitExecFileSync: gitExecFileSyncMock })) -vi.mock('./status', () => ({ - resolveGitDir: resolveGitDirMock, +vi.mock('./conflict-status', () => ({ resolveGitDir: resolveGitDirMock })) +vi.mock('./git-read-cache', () => ({ runWithGitReadCacheInvalidation: (run: () => Promise) => run() })) @@ -53,7 +54,8 @@ import { resetWorktreeRemovalState } from './remove-worktree-test-harness' -import { forceDeleteLocalBranch, removeWorktree } from './worktree' +import { forceDeleteLocalBranch } from './worktree-branch-cleanup' +import { removeWorktree } from './worktree' const mockGitCommands = createGitCommandMocker(gitExecFileAsyncMock) const getGitCalls = createGitCallReader(gitExecFileAsyncMock) diff --git a/src/main/git/remove-worktree-clean-preflight.test.ts b/src/main/git/remove-worktree-clean-preflight.test.ts index 190074ed151..7003385accd 100644 --- a/src/main/git/remove-worktree-clean-preflight.test.ts +++ b/src/main/git/remove-worktree-clean-preflight.test.ts @@ -34,9 +34,10 @@ vi.mock('./runner', () => ({ gitExecFileSync: gitExecFileSyncMock, translateWslOutputPaths: translateWslOutputPathsMock })) +vi.mock('./git-process-launch', () => ({ gitExecFileSync: gitExecFileSyncMock })) -vi.mock('./status', () => ({ - resolveGitDir: resolveGitDirMock, +vi.mock('./conflict-status', () => ({ resolveGitDir: resolveGitDirMock })) +vi.mock('./git-read-cache', () => ({ runWithGitReadCacheInvalidation: (run: () => Promise) => run() })) @@ -47,7 +48,10 @@ vi.mock('fs/promises', async () => { import { resetWorktreeRemovalState } from './remove-worktree-test-harness' -import { assertWorktreeCleanForRemoval, WORKTREE_REMOVAL_PREFLIGHT_TIMEOUT_MS } from './worktree' +import { + assertWorktreeCleanForRemoval, + WORKTREE_REMOVAL_PREFLIGHT_TIMEOUT_MS +} from './worktree-removal-preflight' beforeEach(() => { resetWorktreeRemovalState({ diff --git a/src/main/git/remove-worktree-test-harness.ts b/src/main/git/remove-worktree-test-harness.ts index 9d9a7d18b14..a799f77e1b9 100644 --- a/src/main/git/remove-worktree-test-harness.ts +++ b/src/main/git/remove-worktree-test-harness.ts @@ -1,7 +1,7 @@ import { expect, type Mock } from 'vitest' import { clearGitCapabilityStateForTests } from './git-capability-state' -import { _resetWorktreeScanCacheForTests } from './worktree' +import { resetWorktreeScanCache as _resetWorktreeScanCacheForTests } from './worktree-listing' export type MockResult = { error?: Error diff --git a/src/main/git/remove-worktree.test.ts b/src/main/git/remove-worktree.test.ts index be1ddad90e4..be9e3157c72 100644 --- a/src/main/git/remove-worktree.test.ts +++ b/src/main/git/remove-worktree.test.ts @@ -34,9 +34,10 @@ vi.mock('./runner', () => ({ gitExecFileSync: gitExecFileSyncMock, translateWslOutputPaths: translateWslOutputPathsMock })) +vi.mock('./git-process-launch', () => ({ gitExecFileSync: gitExecFileSyncMock })) -vi.mock('./status', () => ({ - resolveGitDir: resolveGitDirMock, +vi.mock('./conflict-status', () => ({ resolveGitDir: resolveGitDirMock })) +vi.mock('./git-read-cache', () => ({ runWithGitReadCacheInvalidation: (run: () => Promise) => run() })) @@ -53,7 +54,8 @@ import { resetWorktreeRemovalState } from './remove-worktree-test-harness' -import { removeWorktree, WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS } from './worktree' +import { removeWorktree } from './worktree' +import { WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS } from './worktree-registration-repair' const mockGitCommands = createGitCommandMocker(gitExecFileAsyncMock) const getGitCalls = createGitCallReader(gitExecFileAsyncMock) diff --git a/src/main/git/repo-default-base-timeout.test.ts b/src/main/git/repo-default-base-timeout.test.ts index b7c1518bc77..99434548567 100644 --- a/src/main/git/repo-default-base-timeout.test.ts +++ b/src/main/git/repo-default-base-timeout.test.ts @@ -8,8 +8,9 @@ vi.mock('./runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock, gitExecFileSync: vi.fn() })) +vi.mock('./git-process-launch', () => ({ gitExecFileSync: vi.fn() })) -import { getBaseRefDefault } from './repo' +import { getBaseRefDefault } from './default-base-ref' describe('getBaseRefDefault async subprocess bounds', () => { beforeEach(() => { diff --git a/src/main/git/repo-remote-drift-real.test.ts b/src/main/git/repo-remote-drift-real.test.ts index 3ee2f7c45c9..9734bce646c 100644 --- a/src/main/git/repo-remote-drift-real.test.ts +++ b/src/main/git/repo-remote-drift-real.test.ts @@ -3,7 +3,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { getRecentDriftSubjects, getRemoteDrift } from './repo' +import { getRecentDriftSubjects, getRemoteDrift } from './remote-drift' describe('remote drift real Git contract', () => { const tempPaths: string[] = [] diff --git a/src/main/git/repo-remote-drift.test.ts b/src/main/git/repo-remote-drift.test.ts index 3d0b7ef10e9..d1b3c2b9983 100644 --- a/src/main/git/repo-remote-drift.test.ts +++ b/src/main/git/repo-remote-drift.test.ts @@ -15,7 +15,7 @@ vi.mock('./runner', async (importOriginal) => { } }) -import { getRecentDriftSubjects, getRemoteDrift } from './repo' +import { getRecentDriftSubjects, getRemoteDrift } from './remote-drift' describe('remote drift Git probes', () => { beforeEach(() => { diff --git a/src/main/git/repo-search-ref-compat.test.ts b/src/main/git/repo-search-ref-compat.test.ts index 7c29357f55d..06d7d609a07 100644 --- a/src/main/git/repo-search-ref-compat.test.ts +++ b/src/main/git/repo-search-ref-compat.test.ts @@ -8,9 +8,10 @@ vi.mock('./runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock, gitExecFileSync: vi.fn() })) +vi.mock('./git-process-launch', () => ({ gitExecFileSync: vi.fn() })) import { clearGitCapabilityStateForTests } from './git-capability-state' -import { searchBaseRefs } from './repo' +import { searchBaseRefs } from './base-ref-search' describe('searchBaseRefs git compatibility', () => { afterEach(() => { diff --git a/src/main/git/repo-username.test.ts b/src/main/git/repo-username.test.ts index 8d2180791b6..515db81f3e7 100644 --- a/src/main/git/repo-username.test.ts +++ b/src/main/git/repo-username.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as RunnerModule from './runner' +import type * as GitHubCliRunnerModule from './github-cli-runner' const gitExecFileAsyncMock = vi.hoisted(() => vi.fn()) const ghExecFileAsyncMock = vi.hoisted(() => vi.fn()) @@ -8,7 +9,13 @@ vi.mock('./runner', async () => { const actual = await vi.importActual('./runner') return { ...actual, - gitExecFileAsync: gitExecFileAsyncMock, + gitExecFileAsync: gitExecFileAsyncMock + } +}) +vi.mock('./github-cli-runner', async () => { + const actual = await vi.importActual('./github-cli-runner') + return { + ...actual, ghExecFileAsync: ghExecFileAsyncMock } }) diff --git a/src/main/git/repo.test.ts b/src/main/git/repo.test.ts index 90199d1c69b..cb323f6fc19 100644 --- a/src/main/git/repo.test.ts +++ b/src/main/git/repo.test.ts @@ -4,16 +4,15 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' +import { getDefaultBaseRef, resolveDefaultBaseRefViaExec } from './default-base-ref' +import { getBranchConflictKind } from './branch-conflict-detection' +import { getRemoteCount } from './repository-remotes' import { buildSearchBaseRefsArgv, - getDefaultBaseRef, - getBranchConflictKind, - getRemoteCount, parseAndFilterSearchRefDetails, - resolveDefaultBaseRefViaExec, searchBaseRefDetails, searchBaseRefs -} from './repo' +} from './base-ref-search' // Why: use real git state (not mocked) because the bug is in the for-each-ref glob shape a mock would miss. diff --git a/src/main/git/repo.ts b/src/main/git/repo.ts index 50d8a278be2..7d068d00ceb 100644 --- a/src/main/git/repo.ts +++ b/src/main/git/repo.ts @@ -1,63 +1,14 @@ -/* oxlint-disable max-lines */ -import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs' -import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path' -import { gitExecFileSync, gitExecFileAsync } from './runner' -import type { BaseRefSearchResult } from '../../shared/repo-types' -import { parseGitRevListAheadBehindCounts } from '../../shared/git-rev-list-output' +import { existsSync, statSync } from 'node:fs' +import { basename, dirname, resolve } from 'node:path' import { normalizeRuntimePathSeparators } from '../../shared/cross-platform-path' -import { isForEachRefExcludeUnsupportedError } from '../../shared/git-ref-command-capabilities' import { parseWslUncPath } from '../../shared/wsl-paths' import { toWindowsWslPath } from '../wsl' -import { buildHostedRemoteCommitUrl, buildHostedRemoteFileUrl } from './hosted-remote-url' -import { getLocalGitCapabilityCache } from './git-capability-state' - -type LocalGitExecOptions = { - wslDistro?: string -} - -type LocalDefaultBaseRefGitOptions = { - cwd: string - wslDistro?: string -} - -const DEFAULT_BASE_REF_PROBE_TIMEOUT_MS = 15_000 +import { gitExecFileSync } from './git-process-launch' +import { scanGitMarkerSync, resolveRealPathSync } from './repository-marker-detection' type GitRepoProbeResult = 'repo' | 'not-repo' | 'indeterminate' -type GitMarkerScanResult = { status: 'valid'; rootPath: string } | { status: 'absent' | 'invalid' } -function gitExecOptions( - cwd: string, - options: LocalGitExecOptions = {} -): { cwd: string; wslDistro?: string } { - return options.wslDistro ? { cwd, wslDistro: options.wslDistro } : { cwd } -} - -/** - * Ordered probe list for a repo's default base ref when no origin/HEAD symbolic-ref is set. - * `returnAs` is the short-name format the UI expects (as `for-each-ref --format=%(refname:short)` renders it). - * Shared local/SSH so both resolve identical defaults. - */ -export const DEFAULT_BASE_REF_PROBES: readonly { ref: string; returnAs: string }[] = [ - { ref: 'refs/remotes/origin/main', returnAs: 'origin/main' }, - { ref: 'refs/remotes/origin/master', returnAs: 'origin/master' }, - { ref: 'refs/heads/main', returnAs: 'main' }, - { ref: 'refs/heads/master', returnAs: 'master' } -] - -/** - * Walk DEFAULT_BASE_REF_PROBES in order, returning the first ref `hasRef` confirms, or null. - * Abstracts the existence test so local and SSH paths share one authoritative probe ordering. - */ -async function resolveDefaultBaseRefFromProbes( - hasRef: (ref: string) => Promise -): Promise { - for (const { ref, returnAs } of DEFAULT_BASE_REF_PROBES) { - if (await hasRef(ref)) { - return returnAs - } - } - return null -} +let warnedMarkerFallbackThisSession = false /** Check if a path is a valid git repository (regular or bare). */ export function isGitRepo(path: string): boolean { @@ -69,7 +20,6 @@ export function isGitRepo(path: string): boolean { return false } - // Ask git directly first — authoritative for work trees, linked worktrees, submodules, and bare repos. const gitProbeResult = probeGitRepo(path) if (gitProbeResult === 'repo') { return true @@ -78,11 +28,8 @@ export function isGitRepo(path: string): boolean { return false } - // Why: rev-parse can fail for reasons unrelated to repo-ness (spawn hiccup, config error); fall back to a - // validated `.git` marker instead of downgrading a real repo to a plain folder (the spurious "Open as Folder" bug). const markerScan = scanGitMarkerSync(path) if (markerScan.status === 'valid' && !warnedMarkerFallbackThisSession) { - // Why: warn once per session; the folder scanner calls isGitRepo for many paths and would otherwise flood logs. warnedMarkerFallbackThisSession = true console.warn('[isGitRepo] git rev-parse could not confirm repo; accepted via .git marker', { path @@ -91,15 +38,9 @@ export function isGitRepo(path: string): boolean { return markerScan.status === 'valid' } -let warnedMarkerFallbackThisSession = false - -/** - * Tri-state git probe: only a clean pair of negative answers is a definitive - * non-repo. Spawn/config failures stay indeterminate so marker fallback can run. - */ +/** Only a clean pair of negative Git answers definitively means non-repository. */ function probeGitRepo(path: string): GitRepoProbeResult { let sawFailure = false - try { const insideWorkTree = gitExecFileSync(['rev-parse', '--is-inside-work-tree'], { cwd: path @@ -115,9 +56,7 @@ function probeGitRepo(path: string): GitRepoProbeResult { } try { - const bareRepo = gitExecFileSync(['rev-parse', '--is-bare-repository'], { - cwd: path - }).trim() + const bareRepo = gitExecFileSync(['rev-parse', '--is-bare-repository'], { cwd: path }).trim() if (bareRepo === 'true') { return 'repo' } @@ -127,7 +66,6 @@ function probeGitRepo(path: string): GitRepoProbeResult { } catch { sawFailure = true } - return sawFailure ? 'indeterminate' : 'not-repo' } @@ -140,32 +78,19 @@ export function getGitRepoRoot(path: string): string { cwd: path }).trim() if (insideWorkTree === 'true') { - const root = gitExecFileSync(['rev-parse', '--show-toplevel'], { - cwd: path - }).trim() + const root = gitExecFileSync(['rev-parse', '--show-toplevel'], { cwd: path }).trim() return normalizeGitRepoRootForInputPath(path, root) } } catch { - // Fall through to preserving the original path. + // Fall through to the validated filesystem marker and then preserve the original path. } const markerScan = scanGitMarkerSync(path) - if (markerScan.status === 'valid') { - return normalizeGitRepoRootForInputPath(path, markerScan.rootPath) - } - return path + return markerScan.status === 'valid' + ? normalizeGitRepoRootForInputPath(path, markerScan.rootPath) + : path } -function canonicalizeGitDirPath(path: string): string { - return resolveRealPathSync(path) ?? path -} - -/** - * Main-checkout path when `path` is a *linked* worktree, else null (main worktree, bare repo, - * non-repo, or any git failure). A linked worktree's `--git-dir` is `/worktrees/` - * while the main worktree's equals `--git-common-dir`; comparing the two from one invocation is - * git's own canonical test and avoids symlink-canonicalization mismatches. Baseline-safe: both - * flags long predate Git 2.25, and a relative answer resolves against `path` as old Git reports it. - */ +/** Return the main-checkout path when `path` is a linked worktree, otherwise null. */ export function getLinkedWorktreeMainRepoRoot(path: string): string | null { try { if (!existsSync(path) || !statSync(path).isDirectory()) { @@ -182,19 +107,12 @@ export function getLinkedWorktreeMainRepoRoot(path: string): string | null { if (!gitDir || !commonDir) { return null } - // Why realpath both: git answers one flag absolutely (already symlink-resolved) and the other - // relative to cwd, so a repo under a symlinked root (macOS /var -> /private/var) compares - // unequal on raw strings and a main checkout gets misread as a linked worktree. - const absoluteCommonDir = canonicalizeGitDirPath(resolve(path, commonDir)) - if (canonicalizeGitDirPath(resolve(path, gitDir)) === absoluteCommonDir) { + const absoluteCommonDir = + resolveRealPathSync(resolve(path, commonDir)) ?? resolve(path, commonDir) + const absoluteGitDir = resolveRealPathSync(resolve(path, gitDir)) ?? resolve(path, gitDir) + if (absoluteGitDir === absoluteCommonDir || basename(absoluteCommonDir) !== '.git') { return null } - // A bare/separate git dir has no adjacent working checkout to point at. - if (basename(absoluteCommonDir) !== '.git') { - return null - } - // Re-resolve through getGitRepoRoot so the returned path matches the canonical form - // add-project stores for the main checkout (symlinks resolved the way git reports them). return getGitRepoRoot(dirname(absoluteCommonDir)) } catch { return null @@ -204,858 +122,13 @@ export function getLinkedWorktreeMainRepoRoot(path: string): string | null { export function normalizeGitRepoRootForInputPath(inputPath: string, rootPath: string): string { const inputWsl = parseWslUncPath(inputPath) if (inputWsl && rootPath.startsWith('/')) { - // Why: WSL git reports Linux-native roots; persist the UNC path so later git calls keep routing through the WSL runner. return toWindowsWslPath(rootPath, inputWsl.distro) } return normalizeRuntimePathSeparators(rootPath) } -/** - * Filesystem-only fallback check for genuine Git metadata when git can't answer cleanly. Strict enough to - * reject a garbage `.git` file (validation from 18ed7b27d): accepts a `.git` dir/file with real gitdir shape - * or a bare-repo root (HEAD + objects/ + refs/, not a worktree admin dir). - */ -function scanGitMarkerSync(path: string): GitMarkerScanResult { - const realPath = resolveRealPathSync(path) - if (realPath && realPath !== path) { - const lexicalScan = scanGitMarkerAncestorsSync(path) - const realPathScan = scanGitMarkerAncestorsSync(realPath) - if ( - lexicalScan.status === 'valid' && - realPathScan.status === 'valid' && - pathsReferToSameEntry(lexicalScan.rootPath, realPathScan.rootPath) - ) { - // Why: preserve lexical spellings (/var vs /private/var), but let a cross-repo symlink bind to the real target like git. - return lexicalScan - } - return realPathScan - } - return scanGitMarkerAncestorsSync(path) -} - -function resolveRealPathSync(path: string): string | null { - try { - return realpathSync.native(path) - } catch { - try { - return realpathSync(path) - } catch { - return null - } - } -} - -function scanGitMarkerAncestorsSync(path: string): GitMarkerScanResult { - for (const candidate of ancestorDirectories(path)) { - if (!isInsideDotGitMarker(candidate, path)) { - const worktreeMarker = scanWorktreeMarkerSync(candidate) - if (worktreeMarker.status !== 'absent') { - return worktreeMarker - } - } - if (hasValidBareRepoMarkerSync(candidate)) { - return { status: 'valid', rootPath: candidate } - } - } - return { status: 'absent' } -} - -function ancestorDirectories(path: string): string[] { - const directories: string[] = [] - let current = path - while (true) { - directories.push(current) - const parent = dirname(current) - if (parent === current) { - return directories - } - current = parent - } -} - -function isInsideDotGitMarker(rootPath: string, targetPath: string): boolean { - const relativePath = relative(rootPath, targetPath) - if (!relativePath || relativePath.startsWith('..') || isAbsolute(relativePath)) { - return false - } - const firstSegment = relativePath.split(/[\\/]+/)[0] - if (firstSegment === '.git') { - return true - } - if (firstSegment.toLowerCase() !== '.git') { - return false - } - return pathsReferToSameEntry(join(rootPath, firstSegment), join(rootPath, '.git')) -} - -function pathsReferToSameEntry(leftPath: string, rightPath: string): boolean { - try { - const leftStat = statSync(leftPath) - const rightStat = statSync(rightPath) - if (leftStat.ino !== 0 && leftStat.dev === rightStat.dev && leftStat.ino === rightStat.ino) { - return true - } - const leftRealPath = normalizeRuntimePathSeparators(realpathSync.native(leftPath)) - const rightRealPath = normalizeRuntimePathSeparators(realpathSync.native(rightPath)) - return process.platform === 'win32' - ? leftRealPath.toLowerCase() === rightRealPath.toLowerCase() - : leftRealPath === rightRealPath - } catch { - return false - } -} - -function scanWorktreeMarkerSync(worktreePath: string): GitMarkerScanResult { - const dotGit = join(worktreePath, '.git') - let marker: ReturnType - try { - marker = statSync(dotGit) - } catch { - return { status: 'absent' } - } - - if (marker.isDirectory()) { - return hasValidGitDirectorySync(dotGit) - ? { status: 'valid', rootPath: worktreePath } - : { status: 'invalid' } - } - if (marker.isFile()) { - let gitDir: string | null - try { - gitDir = parseGitdirFile(worktreePath, readFileSync(dotGit, 'utf8')) - } catch { - return { status: 'invalid' } - } - return gitDir !== null && hasValidGitDirectorySync(gitDir) - ? { status: 'valid', rootPath: worktreePath } - : { status: 'invalid' } - } - return { status: 'invalid' } -} - -function parseGitdirFile(basePath: string, content: string): string | null { - const firstLine = content.split(/\r?\n/, 1)[0] ?? '' - const match = firstLine.match(/^gitdir:\s*(.+?)\s*$/i) - if (!match) { - return null - } - return resolveGitMetadataPath(basePath, match[1]) -} - -function resolveGitMetadataPath(basePath: string, rawPath: string): string | null { - const value = rawPath.trim() - if (!value) { - return null - } - const baseWsl = parseWslUncPath(basePath) - if (baseWsl && value.startsWith('/')) { - return toWindowsWslPath(value, baseWsl.distro) - } - return isAbsolute(value) ? value : resolve(basePath, value) -} - -function hasValidGitDirectorySync(gitDir: string): boolean { - return hasValidCommonGitDirectorySync(gitDir) || hasValidLinkedWorktreeGitDirectorySync(gitDir) -} - -function hasValidCommonGitDirectorySync(gitDir: string): boolean { - try { - return ( - statSync(join(gitDir, 'HEAD')).isFile() && - statSync(join(gitDir, 'objects')).isDirectory() && - statSync(join(gitDir, 'refs')).isDirectory() - ) - } catch { - return false - } -} - -function hasValidLinkedWorktreeGitDirectorySync(gitDir: string): boolean { - try { - if (!statSync(join(gitDir, 'HEAD')).isFile() || !statSync(join(gitDir, 'commondir')).isFile()) { - return false - } - const commonDir = resolveGitMetadataPath( - gitDir, - readFileSync(join(gitDir, 'commondir'), 'utf8') - ) - return commonDir !== null && hasValidCommonGitDirectorySync(commonDir) - } catch { - return false - } -} - -function hasValidBareRepoMarkerSync(path: string): boolean { - return hasValidCommonGitDirectorySync(path) && !gitConfigDeclaresNonBare(path) -} - -function gitConfigDeclaresNonBare(gitDir: string): boolean { - try { - const config = readFileSync(join(gitDir, 'config'), 'utf8') - let inCoreSection = false - for (const line of config.split(/\r?\n/)) { - const section = line.match(/^\s*\[([^\]]+)\]/) - if (section) { - inCoreSection = section[1].trim().toLowerCase() === 'core' - continue - } - const bare = line.match(/^\s*bare\s*=\s*(.*?)\s*$/i) - if (inCoreSection && bare) { - return isGitBooleanFalse(normalizeGitConfigValue(bare[1])) - } - } - return false - } catch { - return false - } -} - -function normalizeGitConfigValue(value: string): string { - const unescaped = stripGitConfigInlineComment(value).trim().replace(/\\"/g, '"') - if ( - unescaped.length >= 2 && - ((unescaped.startsWith('"') && unescaped.endsWith('"')) || - (unescaped.startsWith("'") && unescaped.endsWith("'"))) - ) { - return unescaped.slice(1, -1) - } - return unescaped -} - -function stripGitConfigInlineComment(value: string): string { - let quote: '"' | "'" | null = null - let escaped = false - for (let i = 0; i < value.length; i++) { - const char = value[i] - if (escaped) { - escaped = false - continue - } - if (char === '\\') { - escaped = true - continue - } - if (quote) { - if (char === quote) { - quote = null - } - continue - } - if (char === '"' || char === "'") { - quote = char - continue - } - if (char === '#' || char === ';') { - return value.slice(0, i) - } - } - return value -} - -function isGitBooleanFalse(value: string): boolean { - return ['', 'false', 'no', 'off', '0'].includes(value.toLowerCase()) -} - -/** Get a human-readable name for the repo from its path. */ +/** Get a human-readable repository name from its path. */ export function getRepoName(path: string): string { const name = basename(path) - // Strip .git suffix from bare repos return name.endsWith('.git') ? name.slice(0, -4) : name } - -/** Get the remote origin URL, or null if not set. */ -export function getRemoteUrl(path: string): string | null { - try { - return getRemoteUrlByName(path, 'origin') - } catch { - return null - } -} - -function getRemoteUrlByName(path: string, remote: string): string { - return gitExecFileSync(['remote', 'get-url', remote], { - cwd: path - }).trim() -} - -function hasGitRef(path: string, ref: string): boolean { - try { - gitExecFileSync(['rev-parse', '--verify', ref], { - cwd: path - }) - return true - } catch { - return false - } -} - -function gitRefToDefaultBaseRef(ref: string): string { - return ref.replace(/^refs\/remotes\//, '') -} - -function getVerifiedOriginHeadBaseRef(path: string): string | null { - try { - const ref = gitExecFileSync(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], { - cwd: path - }).trim() - - // Why: origin/HEAD may survive a default-branch rename pointing at a deleted ref; verify before trusting it. - return ref && hasGitRef(path, ref) ? gitRefToDefaultBaseRef(ref) : null - } catch { - return null - } -} - -/** - * Resolve the default base ref for new worktrees, preferring the remote primary over a stale local branch. - * Returns null when nothing resolves (rather than a hardcoded `origin/main`) so callers fail loudly or degrade. - */ -export function getDefaultBaseRef(path: string): string | null { - const originHeadBaseRef = getVerifiedOriginHeadBaseRef(path) - if (originHeadBaseRef) { - return originHeadBaseRef - } - - // Why: walk the shared DEFAULT_BASE_REF_PROBES so sync and async/SSH paths can't drift on ref order. - for (const { ref, returnAs } of DEFAULT_BASE_REF_PROBES) { - if (hasGitRef(path, ref)) { - return returnAs - } - } - return null -} - -export async function getBaseRefDefault( - path: string, - options: LocalGitExecOptions = {} -): Promise { - return getDefaultBaseRefAsync(path, options) -} - -/** - * Return { ahead, behind } (merge-base-symmetric delta) for localRef vs remoteRef, or null on failure. - * ahead = commits on localRef not in remoteRef; behind = the reverse. Used by the stale-base dispatch guard (§3.1). - * Async because this runs before every orchestration dispatch on Electron main. - */ -export async function getRemoteDrift( - repoPath: string, - localRef: string, - remoteRef: string, - options: LocalGitExecOptions = {} -): Promise<{ ahead: number; behind: number } | null> { - try { - const { stdout } = await gitExecFileAsync( - ['rev-list', '--left-right', '--count', `${localRef}...${remoteRef}`], - { - ...gitExecOptions(repoPath, options), - timeout: DEFAULT_BASE_REF_PROBE_TIMEOUT_MS - } - ) - const counts = parseGitRevListAheadBehindCounts(stdout) - if (counts.status !== 'ok') { - return null - } - return { ahead: counts.ahead, behind: counts.behind } - } catch { - return null - } -} - -/** - * Up to `limit` commit subjects on remoteRef but not localRef, recency order; [] on git failure. - * Powers the preamble drift section (§3.2) so a worker sees whether stale-base drift touches its area. - */ -export async function getRecentDriftSubjects( - repoPath: string, - localRef: string, - remoteRef: string, - limit: number, - options: LocalGitExecOptions = {} -): Promise { - try { - const { stdout } = await gitExecFileAsync( - ['log', '--format=%s', '-n', String(limit), `${localRef}..${remoteRef}`], - { - ...gitExecOptions(repoPath, options), - timeout: DEFAULT_BASE_REF_PROBE_TIMEOUT_MS - } - ) - return stdout.split('\n').filter((s) => s.trim().length > 0) - } catch { - return [] - } -} - -/** Parse `git remote` stdout into a remote count. Shared local/SSH so count semantics can't drift. */ -export function parseRemoteCount(stdout: string): number { - return stdout.split('\n').filter((line) => line.trim().length > 0).length -} - -/** Count configured remotes via `git remote`; returns 0 on error (callers read 0 as "unknown / no hint"). */ -export async function getRemoteCount(path: string): Promise { - try { - const { stdout } = await gitExecFileAsync(['remote'], { cwd: path }) - return parseRemoteCount(stdout) - } catch (err) { - // Why: log so a missing multi-remote hint is debuggable; callers still treat 0 as "unknown". - console.warn('[getRemoteCount] git remote failed', { path, err }) - return 0 - } -} - -/** Callback shape for a git exec function that yields stdout. */ -export type GitExec = (argv: string[]) => Promise<{ stdout: string }> - -async function hasGitRefViaExec(exec: GitExec, ref: string): Promise { - try { - await exec(['rev-parse', '--verify', '--quiet', ref]) - return true - } catch { - return false - } -} - -async function resolveVerifiedOriginHeadBaseRefViaExec(exec: GitExec): Promise { - try { - const { stdout } = await exec(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD']) - const ref = stdout.trim() - if (!ref || !(await hasGitRefViaExec(exec, ref))) { - return null - } - return gitRefToDefaultBaseRef(ref) - } catch { - return null - } -} - -/** - * Resolve the default base ref via a git exec callback: prefer origin/HEAD's symbolic-ref target, - * else fall back to DEFAULT_BASE_REF_PROBES. Shared local/SSH so both transports agree. - * - * Why swallow symbolic-ref's error: a non-zero exit is the expected "origin/HEAD unset" signal, not a failure. - */ -export async function resolveDefaultBaseRefViaExec(exec: GitExec): Promise { - const originHeadBaseRef = await resolveVerifiedOriginHeadBaseRefViaExec(exec) - if (originHeadBaseRef) { - return originHeadBaseRef - } - return resolveDefaultBaseRefFromProbes((ref) => hasGitRefViaExec(exec, ref)) -} - -export function resolveDefaultBaseRefWithLocalGit( - options: LocalDefaultBaseRefGitOptions -): Promise { - return resolveDefaultBaseRefViaExec((argv) => - gitExecFileAsync(argv, { - ...options, - // Why: async avoids main-thread stalls, but dead local/WSL filesystems still need a bound. - timeout: DEFAULT_BASE_REF_PROBE_TIMEOUT_MS - }) - ) -} - -async function getDefaultBaseRefAsync( - path: string, - options: LocalGitExecOptions = {} -): Promise { - return resolveDefaultBaseRefWithLocalGit(gitExecOptions(path, options)) -} - -/** - * Build the argv for `git for-each-ref` used by ref search, given an already-normalized query. - * - * Why: glob every remote (`refs/remotes/*\/*`), not just origin, so fork workflows can find branches like - * `upstream/main` — see docs/upstream-base-ref-design.md. Shared with the SSH relay path so argv can't diverge. - */ -const REF_SEARCH_CANDIDATE_MULTIPLIER = 4 -const REF_SEARCH_LEGACY_HEADROOM = 100 - -type RefSearchPatternGroup = 'all' | 'segmented' | 'branchRoot' - -function getRefSearchTokens(normalizedQuery: string): string[] { - return normalizedQuery.split('/').filter((t) => t.length > 0) -} - -function getRefSearchCandidateCount(limit: number, excludesRemoteHead: boolean): number { - if (!Number.isInteger(limit) || limit <= 0) { - throw new Error('invalid_limit') - } - const baseCount = limit * REF_SEARCH_CANDIDATE_MULTIPLIER - return excludesRemoteHead ? baseCount : baseCount + REF_SEARCH_LEGACY_HEADROOM -} - -export function buildSearchBaseRefsArgv( - normalizedQuery: string, - limit: number, - options: { - excludeRemoteHead?: boolean - remoteNames?: readonly string[] - patternGroup?: RefSearchPatternGroup - } = {} -): string[] { - const excludeRemoteHead = options.excludeRemoteHead ?? true - const candidateCount = getRefSearchCandidateCount(limit, excludeRemoteHead) - const base = [ - 'for-each-ref', - '--format=%(refname)%00%(refname:short)', - '--sort=-committerdate', - ...(excludeRemoteHead - ? [ - // Why: exclude remote HEAD pseudo-refs before --count so the candidate window holds displayable refs. - '--exclude=refs/remotes/**/HEAD' - ] - : []), - // Why: cap git output so broad globs don't overflow execFile/SSH buffers in very large repos. - `--count=${candidateCount}` - ] - // Why: split on `/` so display-format queries route each token to one ref segment; filter empties from stray slashes. - const tokens = getRefSearchTokens(normalizedQuery) - if (tokens.length <= 1) { - const q = tokens[0] ?? '' - // Why `**` not `*`: fnmatch `*` can't cross `/`, so match slash-named branches at both leaf and ancestor segments. - return [ - ...base, - `refs/heads/**/*${q}*`, - `refs/heads/**/*${q}*/**`, - `refs/remotes/**/*${q}*`, - `refs/remotes/**/*${q}*/**` - ] - } - // Why: one `*token*` per ref segment because fnmatch `*` can't cross `/`; lets a retyped `/` result match. - const segmented = tokens.map((token) => `*${token}*`).join('/') - const substringQuery = tokens.join('/') - const remoteBranchRootPatterns = - options.remoteNames && options.remoteNames.length > 0 - ? options.remoteNames.flatMap((remote) => [ - `refs/remotes/${remote}/${substringQuery}*`, - `refs/remotes/${remote}/${substringQuery}*/**` - ]) - : [`refs/remotes/*/${substringQuery}*`, `refs/remotes/*/${substringQuery}*/**`] - const segmentedPatterns = [`refs/remotes/${segmented}`, `refs/heads/${segmented}`] - const branchRootPatterns = [ - // Why: branch names often contain slashes (plan/docs); these root patterns also match a local branch beneath any remote. - `refs/heads/${substringQuery}*`, - `refs/heads/${substringQuery}*/**`, - ...remoteBranchRootPatterns - ] - const patterns = - options.patternGroup === 'segmented' - ? segmentedPatterns - : options.patternGroup === 'branchRoot' - ? branchRootPatterns - : [...segmentedPatterns, ...branchRootPatterns] - return [...base, ...patterns] -} - -async function runSearchBaseRefsGit( - path: string, - normalizedQuery: string, - limit: number, - options: { remoteNames: readonly string[]; patternGroup?: RefSearchPatternGroup } -): Promise<{ stdout: string }> { - return getLocalGitCapabilityCache({ cwd: path }).runWithFallback( - 'for-each-ref-exclude', - () => - gitExecFileAsync( - buildSearchBaseRefsArgv(normalizedQuery, limit, { - remoteNames: options.remoteNames, - patternGroup: options.patternGroup - }), - { cwd: path } - ), - () => - gitExecFileAsync( - buildSearchBaseRefsArgv(normalizedQuery, limit, { - excludeRemoteHead: false, - remoteNames: options.remoteNames, - patternGroup: options.patternGroup - }), - { cwd: path } - ), - isForEachRefExcludeUnsupportedError - ) -} - -export function mergeBaseRefSearchResultGroups( - groups: readonly BaseRefSearchResult[][], - limit: number -): BaseRefSearchResult[] { - const seen = new Set() - const merged: BaseRefSearchResult[] = [] - const maxLength = Math.max(0, ...groups.map((group) => group.length)) - for (let index = 0; index < maxLength && merged.length < limit; index += 1) { - for (const group of groups) { - const entry = group[index] - if (!entry || seen.has(entry.refName)) { - continue - } - seen.add(entry.refName) - merged.push(entry) - if (merged.length >= limit) { - break - } - } - } - return merged -} - -export { isForEachRefExcludeUnsupportedError } from '../../shared/git-ref-command-capabilities' - -/** - * Resolve the default push remote for a repo. - * 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, - options: LocalGitExecOptions = {} -): Promise { - const defaultRef = await getDefaultBaseRefAsync(path, options) - // Why: getDefaultBaseRefAsync returns null when no default branch exists; guard so .includes() can't crash. - const defaultBranch = defaultRef - ? defaultRef.includes('/') - ? defaultRef.split('/').slice(1).join('/') - : defaultRef - : null - - if (defaultBranch) { - try { - const { stdout } = await gitExecFileAsync( - ['config', '--get', `branch.${defaultBranch}.remote`], - gitExecOptions(path, options) - ) - const value = stdout.trim() - if (value) { - return value - } - } catch { - // Fall through: branch has no explicit remote configured. - } - } - - try { - const { stdout } = await gitExecFileAsync(['remote'], gitExecOptions(path, options)) - const remotes = stdout - .split('\n') - .map((line) => line.trim()) - .filter(Boolean) - if (remotes.includes('origin')) { - return 'origin' - } - if (remotes.length === 1) { - return remotes[0] - } - if (remotes.length === 0) { - throw new Error('Repo has no configured git remotes.') - } - throw new Error( - `Repo has multiple remotes (${remotes.join(', ')}) and no default is configured. Set branch..remote.` - ) - } catch (error) { - if (error instanceof Error) { - throw error - } - throw new Error('Failed to resolve default remote for repo.') - } -} - -export async function searchBaseRefs(path: string, query: string, limit = 25): Promise { - return (await searchBaseRefDetails(path, query, limit)).map((entry) => entry.refName) -} - -export async function searchBaseRefDetails( - path: string, - query: string, - limit = 25 -): Promise { - if (!Number.isInteger(limit) || limit <= 0) { - return [] - } - const normalizedQuery = normalizeRefSearchQuery(query) - - try { - // Why: argv lives in buildSearchBaseRefsArgv so the SSH sibling cannot drift. - const remotes = await listRemoteNames(path) - const tokens = getRefSearchTokens(normalizedQuery) - if (tokens.length > 1) { - // Why: slash queries need both display-format and local-branch matches; merge before the limit so neither starves. - const results = await Promise.all([ - runSearchBaseRefsGit(path, normalizedQuery, limit, { - remoteNames: remotes, - patternGroup: 'segmented' - }), - runSearchBaseRefsGit(path, normalizedQuery, limit, { - remoteNames: remotes, - patternGroup: 'branchRoot' - }) - ]) - return mergeBaseRefSearchResultGroups( - results.map((entry) => parseAndFilterSearchRefDetails(entry.stdout, limit, remotes)), - limit - ) - } - - const result = await runSearchBaseRefsGit(path, normalizedQuery, limit, { - remoteNames: remotes - }) - return parseAndFilterSearchRefDetails(result.stdout, limit, remotes) - } catch (err) { - // Why: log so a missing result set is debuggable; callers still treat [] as "no matches". - console.warn('[searchBaseRefs] for-each-ref failed', { path, err }) - return [] - } -} - -async function listRemoteNames(path: string, options: LocalGitExecOptions = {}): Promise { - try { - const { stdout } = await gitExecFileAsync(['remote'], gitExecOptions(path, options)) - return stdout - .split('\n') - .map((line) => line.trim()) - .filter(Boolean) - } catch { - return [] - } -} - -export function parseAndFilterSearchRefDetails( - stdout: string, - limit: number, - remotes: string[] = [] -): BaseRefSearchResult[] { - const seen = new Set() - const sortedRemotes = [...remotes].sort((a, b) => b.length - a.length) - return ( - stdout - .split('\n') - .map((line) => line.trim()) - .filter((line) => line.length > 0) - .map((line) => { - const nul = line.indexOf('\0') - if (nul === -1) { - // Why: no NUL means an unexpected %(refname) format; drop it rather than hand callers an unusable "short" ref. - return null - } - return { full: line.slice(0, nul), short: line.slice(nul + 1) } - }) - .filter((entry): entry is { full: string; short: string } => entry !== null) - // Why: drop `/HEAD` pseudo-refs; `.+` (not `[^/]+`) since git allows slashes in remote names. - .filter(({ full }) => !/^refs\/remotes\/.+\/HEAD$/.test(full)) - .filter(({ short }) => { - if (seen.has(short)) { - return false - } - seen.add(short) - return true - }) - .map(({ full, short }) => ({ - refName: short, - localBranchName: resolveLocalBranchName(full, short, sortedRemotes) - })) - // Why: Math.max(0, limit) so pathological limit <= 0 yields zero results, not one. - .slice(0, Math.max(0, limit)) - ) -} - -function resolveLocalBranchName(fullRef: string, shortRef: string, remotes: string[]): string { - const remoteRefPrefix = 'refs/remotes/' - if (!fullRef.startsWith(remoteRefPrefix)) { - return shortRef - } - const remoteAndBranch = fullRef.slice(remoteRefPrefix.length) - const remote = remotes.find((candidate) => remoteAndBranch.startsWith(`${candidate}/`)) - if (remote) { - return remoteAndBranch.slice(remote.length + 1) - } - return remoteAndBranch.split('/').slice(1).join('/') || shortRef -} - -export function normalizeRefSearchQuery(query: string): string { - return query.trim().replace(/[*?[\]\\]/g, '') -} - -async function hasGitRefAsync( - path: string, - ref: string, - options: LocalGitExecOptions = {} -): Promise { - try { - await gitExecFileAsync(['rev-parse', '--verify', ref], gitExecOptions(path, options)) - return true - } catch { - return false - } -} - -export type BranchConflictKind = 'local' | 'remote' - -export async function getBranchConflictKind( - path: string, - branchName: string, - allowedBaseRef?: string, - options: LocalGitExecOptions = {} -): Promise { - if (await hasGitRefAsync(path, `refs/heads/${branchName}`, options)) { - return 'local' - } - - try { - const remoteNames = (await listRemoteNames(path, options)).sort((a, b) => b.length - a.length) - const { stdout } = await gitExecFileAsync( - ['for-each-ref', '--format=%(refname)', 'refs/remotes'], - gitExecOptions(path, options) - ) - const hasRemoteConflict = stdout.split('\n').some((ref) => { - const trimmed = ref.trim() - if (isAllowedRemoteBaseRef(trimmed, allowedBaseRef)) { - return false - } - const shortRef = trimmed.replace(/^refs\/remotes\//, '') - // Why: git allows slashes in remote names; use the configured list so foo/bar/feature resolves to branch "feature". - return resolveLocalBranchName(trimmed, shortRef, remoteNames) === branchName - }) - - return hasRemoteConflict ? 'remote' : null - } catch { - return null - } -} - -function isAllowedRemoteBaseRef(refName: string, allowedBaseRef: string | undefined): boolean { - if (!allowedBaseRef) { - return false - } - const normalizedAllowedRef = allowedBaseRef.startsWith('refs/remotes/') - ? allowedBaseRef - : `refs/remotes/${allowedBaseRef}` - return refName === normalizedAllowedRef -} - -/** Build a hosted URL (GitHub/GitLab/Bitbucket) for a file+line; null when the remote isn't a recognized host. */ -export function getRemoteFileUrl( - repoPath: string, - relativePath: string, - line: number -): string | null { - const remoteUrl = getRemoteUrl(repoPath) - if (!remoteUrl) { - return null - } - - const defaultBaseRef = getDefaultBaseRef(repoPath) - if (!defaultBaseRef) { - return null - } - const defaultBranch = defaultBaseRef.replace(/^origin\//, '') - - return buildHostedRemoteFileUrl(remoteUrl, relativePath, defaultBranch, line) -} - -/** Build a hosted URL (GitHub/GitLab/Bitbucket) for a commit; null when origin isn't a recognized host. */ -export function getRemoteCommitUrl(repoPath: string, sha: string): string | null { - const remoteUrl = getRemoteUrl(repoPath) - if (!remoteUrl) { - return null - } - return buildHostedRemoteCommitUrl(remoteUrl, sha) -} diff --git a/src/main/git/repository-git-execution.ts b/src/main/git/repository-git-execution.ts new file mode 100644 index 00000000000..5fa30849fda --- /dev/null +++ b/src/main/git/repository-git-execution.ts @@ -0,0 +1,10 @@ +export type LocalGitExecOptions = { + wslDistro?: string +} + +export function repositoryGitExecOptions( + cwd: string, + options: LocalGitExecOptions = {} +): { cwd: string; wslDistro?: string } { + return options.wslDistro ? { cwd, wslDistro: options.wslDistro } : { cwd } +} diff --git a/src/main/git/repository-marker-detection.ts b/src/main/git/repository-marker-detection.ts new file mode 100644 index 00000000000..8c69ca08de7 --- /dev/null +++ b/src/main/git/repository-marker-detection.ts @@ -0,0 +1,255 @@ +import { readFileSync, realpathSync, statSync, type Stats } from 'node:fs' +import { dirname, isAbsolute, join, relative, resolve } from 'node:path' +import { normalizeRuntimePathSeparators } from '../../shared/cross-platform-path' +import { parseWslUncPath } from '../../shared/wsl-paths' +import { toWindowsWslPath } from '../wsl' + +export type GitMarkerScanResult = + | { status: 'valid'; rootPath: string } + | { status: 'absent' | 'invalid' } + +/** + * Filesystem-only fallback check for genuine Git metadata when git can't answer cleanly. Strict enough to + * reject a garbage `.git` file: accepts a `.git` dir/file with real gitdir shape or a bare-repo root + * (HEAD + objects/ + refs/, not a worktree admin dir). + */ +export function scanGitMarkerSync(path: string): GitMarkerScanResult { + const realPath = resolveRealPathSync(path) + if (realPath && realPath !== path) { + const lexicalScan = scanGitMarkerAncestorsSync(path) + const realPathScan = scanGitMarkerAncestorsSync(realPath) + if ( + lexicalScan.status === 'valid' && + realPathScan.status === 'valid' && + pathsReferToSameEntry(lexicalScan.rootPath, realPathScan.rootPath) + ) { + // Why: preserve lexical spellings (/var vs /private/var), but let a cross-repo symlink bind to the real target like git. + return lexicalScan + } + return realPathScan + } + return scanGitMarkerAncestorsSync(path) +} + +export function resolveRealPathSync(path: string): string | null { + try { + return realpathSync.native(path) + } catch { + try { + return realpathSync(path) + } catch { + return null + } + } +} + +function scanGitMarkerAncestorsSync(path: string): GitMarkerScanResult { + for (const candidate of ancestorDirectories(path)) { + if (!isInsideDotGitMarker(candidate, path)) { + const worktreeMarker = scanWorktreeMarkerSync(candidate) + if (worktreeMarker.status !== 'absent') { + return worktreeMarker + } + } + if (hasValidBareRepoMarkerSync(candidate)) { + return { status: 'valid', rootPath: candidate } + } + } + return { status: 'absent' } +} + +function ancestorDirectories(path: string): string[] { + const directories: string[] = [] + let current = path + while (true) { + directories.push(current) + const parent = dirname(current) + if (parent === current) { + return directories + } + current = parent + } +} + +function isInsideDotGitMarker(rootPath: string, targetPath: string): boolean { + const relativePath = relative(rootPath, targetPath) + if (!relativePath || relativePath.startsWith('..') || isAbsolute(relativePath)) { + return false + } + const firstSegment = relativePath.split(/[\\/]+/)[0] + if (firstSegment === '.git') { + return true + } + if (firstSegment.toLowerCase() !== '.git') { + return false + } + return pathsReferToSameEntry(join(rootPath, firstSegment), join(rootPath, '.git')) +} + +function pathsReferToSameEntry(leftPath: string, rightPath: string): boolean { + try { + const leftStat = statSync(leftPath) + const rightStat = statSync(rightPath) + if (leftStat.ino !== 0 && leftStat.dev === rightStat.dev && leftStat.ino === rightStat.ino) { + return true + } + const leftRealPath = normalizeRuntimePathSeparators(realpathSync.native(leftPath)) + const rightRealPath = normalizeRuntimePathSeparators(realpathSync.native(rightPath)) + return process.platform === 'win32' + ? leftRealPath.toLowerCase() === rightRealPath.toLowerCase() + : leftRealPath === rightRealPath + } catch { + return false + } +} + +function scanWorktreeMarkerSync(worktreePath: string): GitMarkerScanResult { + const dotGit = join(worktreePath, '.git') + let marker: Stats + try { + marker = statSync(dotGit) + } catch { + return { status: 'absent' } + } + + if (marker.isDirectory()) { + return hasValidGitDirectorySync(dotGit) + ? { status: 'valid', rootPath: worktreePath } + : { status: 'invalid' } + } + if (marker.isFile()) { + let gitDir: string | null + try { + gitDir = parseGitdirFile(worktreePath, readFileSync(dotGit, 'utf8')) + } catch { + return { status: 'invalid' } + } + return gitDir !== null && hasValidGitDirectorySync(gitDir) + ? { status: 'valid', rootPath: worktreePath } + : { status: 'invalid' } + } + return { status: 'invalid' } +} + +function parseGitdirFile(basePath: string, content: string): string | null { + const firstLine = content.split(/\r?\n/, 1)[0] ?? '' + const match = firstLine.match(/^gitdir:\s*(.+?)\s*$/i) + if (!match) { + return null + } + return resolveGitMetadataPath(basePath, match[1]) +} + +function resolveGitMetadataPath(basePath: string, rawPath: string): string | null { + const value = rawPath.trim() + if (!value) { + return null + } + const baseWsl = parseWslUncPath(basePath) + if (baseWsl && value.startsWith('/')) { + return toWindowsWslPath(value, baseWsl.distro) + } + return isAbsolute(value) ? value : resolve(basePath, value) +} + +function hasValidGitDirectorySync(gitDir: string): boolean { + return hasValidCommonGitDirectorySync(gitDir) || hasValidLinkedWorktreeGitDirectorySync(gitDir) +} + +function hasValidCommonGitDirectorySync(gitDir: string): boolean { + try { + return ( + statSync(join(gitDir, 'HEAD')).isFile() && + statSync(join(gitDir, 'objects')).isDirectory() && + statSync(join(gitDir, 'refs')).isDirectory() + ) + } catch { + return false + } +} + +function hasValidLinkedWorktreeGitDirectorySync(gitDir: string): boolean { + try { + if (!statSync(join(gitDir, 'HEAD')).isFile() || !statSync(join(gitDir, 'commondir')).isFile()) { + return false + } + const commonDir = resolveGitMetadataPath( + gitDir, + readFileSync(join(gitDir, 'commondir'), 'utf8') + ) + return commonDir !== null && hasValidCommonGitDirectorySync(commonDir) + } catch { + return false + } +} + +function hasValidBareRepoMarkerSync(path: string): boolean { + return hasValidCommonGitDirectorySync(path) && !gitConfigDeclaresNonBare(path) +} + +function gitConfigDeclaresNonBare(gitDir: string): boolean { + try { + const config = readFileSync(join(gitDir, 'config'), 'utf8') + let inCoreSection = false + for (const line of config.split(/\r?\n/)) { + const section = line.match(/^\s*\[([^\]]+)\]/) + if (section) { + inCoreSection = section[1].trim().toLowerCase() === 'core' + continue + } + const bare = line.match(/^\s*bare\s*=\s*(.*?)\s*$/i) + if (inCoreSection && bare) { + return isGitBooleanFalse(normalizeGitConfigValue(bare[1])) + } + } + return false + } catch { + return false + } +} + +function normalizeGitConfigValue(value: string): string { + const unescaped = stripGitConfigInlineComment(value).trim().replace(/\\"/g, '"') + if ( + unescaped.length >= 2 && + ((unescaped.startsWith('"') && unescaped.endsWith('"')) || + (unescaped.startsWith("'") && unescaped.endsWith("'"))) + ) { + return unescaped.slice(1, -1) + } + return unescaped +} + +function stripGitConfigInlineComment(value: string): string { + let quote: '"' | "'" | null = null + let escaped = false + for (let i = 0; i < value.length; i++) { + const char = value[i] + if (escaped) { + escaped = false + continue + } + if (char === '\\') { + escaped = true + continue + } + if (quote) { + if (char === quote) { + quote = null + } + continue + } + if (char === '"' || char === "'") { + quote = char + continue + } + if (char === '#' || char === ';') { + return value.slice(0, i) + } + } + return value +} + +function isGitBooleanFalse(value: string): boolean { + return ['', 'false', 'no', 'off', '0'].includes(value.toLowerCase()) +} diff --git a/src/main/git/repository-remotes.ts b/src/main/git/repository-remotes.ts new file mode 100644 index 00000000000..2864a10ce6f --- /dev/null +++ b/src/main/git/repository-remotes.ts @@ -0,0 +1,116 @@ +import { buildHostedRemoteCommitUrl, buildHostedRemoteFileUrl } from './hosted-remote-url' +import { getDefaultBaseRef, getBaseRefDefault } from './default-base-ref' +import { gitExecFileAsync } from './runner' +import { gitExecFileSync } from './git-process-launch' +import { repositoryGitExecOptions, type LocalGitExecOptions } from './repository-git-execution' + +/** Get the remote origin URL, or null if not set. */ +export function getRemoteUrl(path: string): string | null { + try { + return gitExecFileSync(['remote', 'get-url', 'origin'], { cwd: path }).trim() + } catch { + return null + } +} + +/** Parse `git remote` stdout into a remote count. Shared local/SSH so count semantics can't drift. */ +export function parseRemoteCount(stdout: string): number { + return stdout.split('\n').filter((line) => line.trim().length > 0).length +} + +/** Count configured remotes; returns 0 on error so callers read it as unknown/no hint. */ +export async function getRemoteCount(path: string): Promise { + try { + const { stdout } = await gitExecFileAsync(['remote'], { cwd: path }) + return parseRemoteCount(stdout) + } catch (err) { + console.warn('[getRemoteCount] git remote failed', { path, err }) + return 0 + } +} + +/** + * Resolve the default push remote: configured default branch remote, origin, or the sole remote. + */ +export async function getDefaultRemote( + path: string, + options: LocalGitExecOptions = {} +): Promise { + const defaultRef = await getBaseRefDefault(path, options) + const defaultBranch = defaultRef + ? defaultRef.includes('/') + ? defaultRef.split('/').slice(1).join('/') + : defaultRef + : null + + if (defaultBranch) { + try { + const { stdout } = await gitExecFileAsync( + ['config', '--get', `branch.${defaultBranch}.remote`], + repositoryGitExecOptions(path, options) + ) + const value = stdout.trim() + if (value) { + return value + } + } catch { + // Fall through: branch has no explicit remote configured. + } + } + + try { + const { stdout } = await gitExecFileAsync(['remote'], repositoryGitExecOptions(path, options)) + const remotes = stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + if (remotes.includes('origin')) { + return 'origin' + } + if (remotes.length === 1) { + return remotes[0] + } + if (remotes.length === 0) { + throw new Error('Repo has no configured git remotes.') + } + throw new Error( + `Repo has multiple remotes (${remotes.join(', ')}) and no default is configured. Set branch..remote.` + ) + } catch (error) { + if (error instanceof Error) { + throw error + } + throw new Error('Failed to resolve default remote for repo.') + } +} + +/** Build a hosted URL for a file and line; null when origin is unsupported or no base ref exists. */ +export function getRemoteFileUrl( + repoPath: string, + relativePath: string, + line: number +): string | null { + const remoteUrl = getRemoteUrl(repoPath) + if (!remoteUrl) { + return null + } + const defaultBaseRef = getDefaultBaseRef(repoPath) + if (!defaultBaseRef) { + return null + } + return buildHostedRemoteFileUrl( + remoteUrl, + relativePath, + defaultBaseRef.replace(/^origin\//, ''), + line + ) +} + +/** Build a hosted URL for a commit; null when origin isn't a recognized host. */ +export function getRemoteCommitUrl(repoPath: string, sha: string): string | null { + const remoteUrl = getRemoteUrl(repoPath) + if (!remoteUrl) { + return null + } + return buildHostedRemoteCommitUrl(remoteUrl, sha) +} diff --git a/src/main/git/runner-command-exec.test.ts b/src/main/git/runner-command-exec.test.ts index e29c9f650cc..8cde403f089 100644 --- a/src/main/git/runner-command-exec.test.ts +++ b/src/main/git/runner-command-exec.test.ts @@ -15,12 +15,12 @@ vi.mock('node:child_process', () => ({ import { commandExecFileAsync, - ghExecFileAsync, gitExecFileAsync, - gitStreamStdout, translateWslOutputPaths, wslAwareSpawn } from './runner' +import { ghExecFileAsync } from './github-cli-runner' +import { gitStreamStdout } from './git-stream' type MockChildProcess = EventEmitter & { stdout: EventEmitter diff --git a/src/main/git/runner-gh-host-args.test.ts b/src/main/git/runner-gh-host-args.test.ts index 708f2d732aa..ceb56aba7e2 100644 --- a/src/main/git/runner-gh-host-args.test.ts +++ b/src/main/git/runner-gh-host-args.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { applyGhHostToArgs } from './runner' +import { applyGhHostToArgs } from './github-cli-policy' describe('applyGhHostToArgs', () => { it('returns args unchanged when no host is given', () => { diff --git a/src/main/git/runner-gh-rate-limit-breaker.test.ts b/src/main/git/runner-gh-rate-limit-breaker.test.ts index 17e14b8e7d6..6b1261ddacc 100644 --- a/src/main/git/runner-gh-rate-limit-breaker.test.ts +++ b/src/main/git/runner-gh-rate-limit-breaker.test.ts @@ -12,7 +12,7 @@ vi.mock('child_process', () => ({ spawn: spawnMock })) -import { ghExecFileAsync } from './runner' +import { ghExecFileAsync } from './github-cli-runner' import { _resetGhRateLimitBreaker, getGhRateLimitBlockedUntilMs, diff --git a/src/main/git/runner-windows-host-environment.test.ts b/src/main/git/runner-windows-host-environment.test.ts index e441b023ff4..06cc2c74e1b 100644 --- a/src/main/git/runner-windows-host-environment.test.ts +++ b/src/main/git/runner-windows-host-environment.test.ts @@ -14,12 +14,12 @@ vi.mock('node:child_process', () => ({ import { awaitWindowsHostGitEnvironmentReady, - configureWindowsHostGitEnvironmentReadiness, gitExecFileAsync, - gitExecFileAsyncBuffer, - gitSpawnAfterWindowsEnvironmentReady, - gitStreamStdout + gitExecFileAsyncBuffer } from './runner' +import { configureWindowsHostGitEnvironmentReadiness } from './command-resolution' +import { gitSpawnAfterWindowsEnvironmentReady } from './git-process-launch' +import { gitStreamStdout } from './git-stream' type MockChildProcess = EventEmitter & { stdout: EventEmitter diff --git a/src/main/git/runner-wsl-direct-read.test.ts b/src/main/git/runner-wsl-direct-read.test.ts index 3aca1dd738e..cfbcfe3b3ab 100644 --- a/src/main/git/runner-wsl-direct-read.test.ts +++ b/src/main/git/runner-wsl-direct-read.test.ts @@ -17,7 +17,9 @@ vi.mock('../observability/instrumentation', () => ({ })) vi.mock('../diagnostics/main-thread-churn-probe', () => ({ recordSubprocessSpawn: vi.fn() })) -import { gitExecFileAsync, gitSpawn, gitStreamStdout } from './runner' +import { gitExecFileAsync } from './runner' +import { gitSpawn } from './git-process-launch' +import { gitStreamStdout } from './git-stream' import { getWslGitReadEnvironment, resetWslGitReadEnvironmentForTests, diff --git a/src/main/git/runner-wsl-gh-fallback.test.ts b/src/main/git/runner-wsl-gh-fallback.test.ts index 57dd86ba26c..1851d935a0c 100644 --- a/src/main/git/runner-wsl-gh-fallback.test.ts +++ b/src/main/git/runner-wsl-gh-fallback.test.ts @@ -20,7 +20,9 @@ vi.mock('../wsl', async (importOriginal) => ({ getDefaultWslDistro: getDefaultWslDistroMock })) -import { ghExecFileAsync, glabExecFileAsync, setDefaultWslDistroOverride } from './runner' +import { ghExecFileAsync } from './github-cli-runner' +import { glabExecFileAsync } from './gitlab-cli-runner' +import { setDefaultWslDistroOverride } from './command-resolution' import { _resetGhRateLimitBreaker } from './gh-rate-limit-breaker' const PRIMARY_RATE_LIMIT_STDERR = diff --git a/src/main/git/runner-wsl-linked-gitdir-windows.test.ts b/src/main/git/runner-wsl-linked-gitdir-windows.test.ts index 1af46a51069..15d5eb0431a 100644 --- a/src/main/git/runner-wsl-linked-gitdir-windows.test.ts +++ b/src/main/git/runner-wsl-linked-gitdir-windows.test.ts @@ -4,14 +4,10 @@ import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { listFilesWithGit } from '../ipc/filesystem-list-files-git-fallback' import { searchWithGitGrep } from '../ipc/filesystem-search-git' -import { - gitExecFileAsync, - gitExecFileAsyncBuffer, - gitStreamStdout, - toLinuxPath, - toWindowsWslPath -} from './runner' -import { listWorktrees } from './worktree' +import { gitExecFileAsync, gitExecFileAsyncBuffer } from './runner' +import { gitStreamStdout } from './git-stream' +import { toLinuxPath, toWindowsWslPath } from '../wsl' +import { listWorktrees } from './worktree-listing' import { resetWslLinkedWorktreeGitRoutingForTests } from './wsl-linked-worktree-git-routing' const distro = process.env.ORCA_TEST_WSL_DISTRO?.trim() diff --git a/src/main/git/runner.test.ts b/src/main/git/runner.test.ts index 30851fe2882..6804f882409 100644 --- a/src/main/git/runner.test.ts +++ b/src/main/git/runner.test.ts @@ -3,17 +3,18 @@ // and stderr extraction from execFile rejections (err.message is unreliable). import { afterEach, describe, expect, it, vi } from 'vitest' import { - appendGitConfigEnv, - extractExecError, - isTransientGhError, nonInteractiveGitEnv, - parseRetryAfterMs, promptGuardGitEnv, promptGuardShellEnv, - redirectPortedHostnameToEnv, untranslatedGitOutputEnv -} from './runner' -import { mergeGitConfigEnvProtocol } from '../../shared/git-credential-prompt-env' +} from './git-environment-policy' +import { extractExecError, parseRetryAfterMs } from './exec-error' +import { isTransientGhError } from './github-cli-policy' +import { redirectPortedHostnameToEnv } from './gitlab-cli-runner' +import { + appendGitConfigEnv, + mergeGitConfigEnvProtocol +} from '../../shared/git-credential-prompt-env' // Reads git config injected via the GIT_CONFIG_COUNT/KEY/VALUE env protocol // back into a plain key→value map so tests can assert on it directly. @@ -344,7 +345,6 @@ describe('redirectPortedHostnameToEnv WSLENV forwarding', () => { // A WSL-routed glab only sees Windows variables listed in WSLENV; without // the entry the ported host is silently dropped and glab talks to // gitlab.com (#12557). - const { redirectPortedHostnameToEnv } = await import('./runner') const { options } = redirectPortedHostnameToEnv( ['api', '--hostname', 'gitlab.example.com:8443'], { env: { PATH: '/usr/bin' } } diff --git a/src/main/git/runner.ts b/src/main/git/runner.ts index 1ae53551f29..274779f0270 100644 --- a/src/main/git/runner.ts +++ b/src/main/git/runner.ts @@ -1,204 +1,42 @@ -/* eslint-disable max-lines -- command routing, WSL translation, and git/gh/glab wrappers stay co-located for consistent platform behavior. */ /** - * Centralized git/gh/command runner with transparent WSL support. + * Centralized Git and generic command orchestration with transparent WSL support. * * Why: when a repo lives on a WSL filesystem, native Windows binaries (git.exe, * gh.exe, rg.exe) are absent or slow, so this routes execution through * `wsl.exe -d ` with translated Linux paths. */ -import { - execFile, - execFileSync, - spawn, - type ChildProcess, - type ExecFileOptions, - type SpawnOptions -} from 'node:child_process' -import { StringDecoder } from 'node:string_decoder' +import { spawn, type ChildProcess, type SpawnOptions } from 'node:child_process' import { withGitSpan } from '../observability/instrumentation' import { recordSubprocessSpawn } from '../diagnostics/main-thread-churn-probe' +import { parseWslPath, toWindowsWslPath } from '../wsl' +import { isWindowsBatchScript, resolveWindowsCommand } from '../win32-utils' import { - classifyGhRateLimitBucket, - createGhRateLimitBlockedError, - getGhRateLimitBlockedUntilMs, - ghRateLimitScopeKey, - isGhPrimaryRateLimitStderr, - isGhRateLimitProbe, - notifyGhPrimaryRateLimit, - type GhRateLimitBucket -} from './gh-rate-limit-breaker' -import { getDefaultWslDistro, parseWslPath, toWindowsWslPath, type WslPathInfo } from '../wsl' -import { addWslEnvKeys } from '../wsl-env' + prepareWindowsHostGitEnvironment, + resolveCommand, + type ResolvedCommand +} from './command-resolution' import { - appendGitConfigEnv, - gitCredentialPromptGuardEnv -} from '../../shared/git-credential-prompt-env' -import { getSpawnArgsForWindows, isWindowsBatchScript, resolveWindowsCommand } from '../win32-utils' -import { isWslDirectGitReadCommand } from './wsl-direct-git-read-commands' + directWslGitExitCode, + disableDirectWslGitAfterSuccessfulFallback, + invalidateMissingDirectWslGit, + resolveGitCommand, + type GitExecOptions +} from './git-command-resolution' +import { execFileCapture } from './exec-file-capture' import { - buildWslCapturedLoginShellCommand, - buildWslExecArgs, - buildWslLoginShellCommand, - quotePosixShell, - type WslCapturedLoginShellCommand -} from '../../shared/wsl-login-shell-command' -import { UNTRANSLATED_GIT_OUTPUT_ENV } from '../../shared/git-output-locale' -import { endSubprocessStdin } from '../../shared/subprocess-stdin-write' + buildNetworkSshPolicyEnv, + nonInteractiveGitEnv, + untranslatedGitOutputEnv +} from './git-environment-policy' import { - disableWslGitReadEnvironment, - getWslGitReadEnvironment, - invalidateWslGitReadEnvironment, - peekWslGitReadEnvironment, - type WslGitReadEnvironment -} from './wsl-git-read-environment' + shouldRetryWindowsCommandShim, + spawnCommandCapture, + type CommandExecOptions +} from './spawn-command-capture' import { isWslLinkedWorktreeGitRoutingCandidate, - prepareWslLinkedWorktreeGitRouting, - usesHostGitForWslLinkedWorktree + prepareWslLinkedWorktreeGitRouting } from './wsl-linked-worktree-git-routing' -// Re-exported for existing importers; lightweight consumers should import from './exec-error' to avoid this heavy module. -import { extractExecError, parseRetryAfterMs } from './exec-error' -export { extractExecError, parseRetryAfterMs } - -// ─── Core resolution ──────────────────────────────────────────────── - -// Env-assignment prefix for WSL-routed git, where spawn env can't cross the wsl.exe boundary; values are shell-safe unquoted. -const GIT_OUTPUT_LOCALE_SHELL_PREFIX = Object.entries(UNTRANSLATED_GIT_OUTPUT_ENV) - .map(([key, value]) => `${key}=${value}`) - .join(' ') -const GIT_OUTPUT_LOCALE_ENV_ARGS = Object.entries(UNTRANSLATED_GIT_OUTPUT_ENV).map( - ([key, value]) => `${key}=${value}` -) - -type ResolvedCommand = { - binary: string - args: string[] - cwd: string | undefined - /** Non-null when the command was routed through WSL. */ - wsl: WslPathInfo | null - wslMode: 'direct-git' | 'login-shell' | 'non-login-shell' | null - /** Present only when the caller opted into a fenced login-shell read. */ - captured?: WslCapturedLoginShellCommand -} - -/** - * Translate Windows-style path arguments to Linux paths for commands run in WSL. - * - * Why: callers pass Windows paths as git arguments, which WSL git can't read. - * UNC paths (\\wsl.localhost\…) become native Linux; drive paths (C:\…) → /mnt/c/… - */ -function translateArgsForWsl(args: string[]): string[] { - return args.map(translateArgForWsl) -} - -function translateArgForWsl(arg: string): string { - // WSL UNC path → native linux path - const wslInfo = parseWslPath(arg) - if (wslInfo) { - return wslInfo.linuxPath - } - - // 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 { - for (let i = 0; i < args.length; i++) { - if ( - (args[i] === '--repo' || args[i] === '-R') && - typeof args[i + 1] === 'string' && - args[i + 1].trim() - ) { - return true - } - if (args[i].startsWith('--repo=') || args[i].startsWith('-R=')) { - return args[i].slice(args[i].indexOf('=') + 1).trim().length > 0 - } - if (args[i].startsWith('-R') && args[i].length > 2) { - return args[i].slice(2).trim().length > 0 - } - } - return false -} - -function argsUseGhApiPlaceholders(args: string[]): boolean { - return args.some( - (arg) => arg.includes('{owner}') || arg.includes('{repo}') || arg.includes('{branch}') - ) -} - -function hasExplicitRepoViewTarget(args: string[]): boolean { - const target = args[2] - return ( - args[0] === 'repo' && - args[1] === 'view' && - typeof target === 'string' && - !target.startsWith('-') && - target.includes('/') - ) -} - -function canRunGitHubCliWithoutRepoCwd(args: string[]): boolean { - if (hasExplicitRepoArg(args)) { - return true - } - if (args[0] === 'api') { - return !argsUseGhApiPlaceholders(args) - } - return args[0] === 'auth' || hasExplicitRepoViewTarget(args) -} - -function isMissingCommandInWsl(stderr: string, command: string): boolean { - const s = stderr.toLowerCase() - const c = command.toLowerCase() - return s.includes(`${c}: command not found`) || s.includes(`${c}: not found`) -} - -function canFallBackToHostGitHubCli( - command: 'gh', - args: string[], - resolved: ResolvedCommand, - stderr: string -): boolean { - return ( - process.platform === 'win32' && - resolved.wsl !== null && - isMissingCommandInWsl(stderr, command) && - canRunGitHubCliWithoutRepoCwd(args) - ) -} - -function resolveHostGitHubCli(command: 'gh', args: string[]): ResolvedCommand { - return { - binary: command, - args, - // Why: host gh can't use a WSL UNC cwd; we only fall back for commands with explicit repo/API context, so none is needed. - cwd: undefined, - wsl: null, - wslMode: null - } -} - -let defaultWslDistroOverride: string | null = null -let waitForWindowsHostGitEnvironment: (() => Promise) | null = null - -// Why: allow host commands fallback to route through the user's pinned WSL distro when host execution fails. -export function setDefaultWslDistroOverride(distro: string | null): void { - defaultWslDistroOverride = distro -} - -export function configureWindowsHostGitEnvironmentReadiness( - waitUntilReady: (() => Promise) | null -): void { - waitForWindowsHostGitEnvironment = waitUntilReady -} export async function awaitWindowsHostGitEnvironmentReady(options: { cwd: string @@ -209,870 +47,6 @@ export async function awaitWindowsHostGitEnvironmentReady(options: { await prepareWindowsHostGitEnvironment(resolved, undefined, options.signal) } -function refreshWindowsHostPath(env: NodeJS.ProcessEnv | undefined): NodeJS.ProcessEnv | undefined { - if (!env) { - return undefined - } - const currentPath = process.env.Path ?? process.env.PATH - if (currentPath === undefined) { - return env - } - const next = { ...env } - const pathKeys = Object.keys(next).filter((key) => key.toLowerCase() === 'path') - if (pathKeys.length === 0) { - next[process.env.Path === undefined ? 'PATH' : 'Path'] = currentPath - } else { - for (const key of pathKeys) { - next[key] = currentPath - } - } - return next -} - -function prepareWindowsHostGitEnvironment( - resolved: ResolvedCommand, - env: NodeJS.ProcessEnv | undefined, - signal?: AbortSignal -): Promise | null { - if ( - process.platform !== 'win32' || - resolved.wsl !== null || - waitForWindowsHostGitEnvironment === null - ) { - return null - } - const ready = waitForWindowsHostGitEnvironment().then(() => refreshWindowsHostPath(env)) - if (!signal) { - return ready - } - if (signal.aborted) { - return Promise.reject(createAbortError()) - } - return new Promise((resolve, reject) => { - let settled = false - const cleanup = (): void => signal.removeEventListener('abort', onAbort) - const onAbort = (): void => { - if (settled) { - return - } - settled = true - cleanup() - reject(createAbortError()) - } - signal.addEventListener('abort', onAbort, { once: true }) - ready.then( - (value) => { - if (settled) { - return - } - settled = true - cleanup() - resolve(value) - }, - (error) => { - if (settled) { - return - } - settled = true - cleanup() - reject(error) - } - ) - }) -} - -function resolveDefaultWslCli(command: 'gh' | 'glab', args: string[]): ResolvedCommand | null { - const distro = defaultWslDistroOverride ?? getDefaultWslDistro() - return distro ? resolveCommand(command, args, undefined, distro) : null -} - -function isHostCommandMissing(err: unknown, command: 'gh' | 'glab'): boolean { - if (!err || typeof err !== 'object') { - return false - } - const e = err as { code?: unknown; message?: unknown; syscall?: unknown; path?: unknown } - if (e.code === 'ENOENT') { - return true - } - const message = typeof e.message === 'string' ? e.message.toLowerCase() : '' - return ( - message.includes('enoent') && - (message.includes(command) || e.path === command || e.syscall === 'spawn') - ) -} - -/** - * Resolve whether a command invocation should be routed through wsl.exe. - * - * Why `bash -c "cd … && …"` instead of `--cd`: wsl.exe's --cd fails with - * ERROR_PATH_NOT_FOUND under Node's execFile/spawn in some configs. - */ -function resolveCommand( - command: string, - args: string[], - cwd: string | undefined, - wslDistroOverride?: string, - options: { - useWslLoginShell?: boolean - captureLoginShellOutput?: boolean - wslGitReadEnvironment?: WslGitReadEnvironment - env?: NodeJS.ProcessEnv - } = {} -): ResolvedCommand { - if (process.platform !== 'win32') { - return { binary: command, args, cwd, wsl: null, wslMode: null } - } - - // Why: global gh callers (rate_limit, listAccessibleProjects) have no cwd to derive a distro from; a distro hint still routes through wsl.exe. - // TODO(wsl-default-distro): no default-distro setting yet, so override-less global gh callers fall back to host gh.exe (ENOENT on WSL-only installs). - const cwdWsl = cwd ? parseWslPath(cwd) : null - const wsl: WslPathInfo | null = - cwdWsl ?? (wslDistroOverride ? { distro: wslDistroOverride, linuxPath: '' } : null) - if (!wsl) { - return { binary: command, args, cwd, wsl: null, wslMode: null } - } - - const translatedArgs = translateArgsForWsl(args) - // Why: env on wsl.exe stays Windows-side (WSLENV forwards only named vars), so the locale must ride the command string (issue #7808). - const localePrefix = command === 'git' ? `${GIT_OUTPUT_LOCALE_SHELL_PREFIX} ` : '' - const escapedCommand = quotePosixShell(command) - // Why: shell-escape each arg to prevent word splitting / glob expansion inside the bash -c string. - const escapedArgs = translatedArgs.map(quotePosixShell) - // Why: prepend `cd &&` for a UNC cwd; skip it when only a distro override was given (global gh needs no cwd). - const linuxCwd = cwdWsl?.linuxPath ?? (cwd && wslDistroOverride ? translateArgForWsl(cwd) : null) - const shellCmd = linuxCwd - ? `cd ${quotePosixShell(linuxCwd)} && ${localePrefix}${escapedCommand} ${escapedArgs.join(' ')}` - : `${localePrefix}${escapedCommand} ${escapedArgs.join(' ')}` - - if (command === 'git' && options.wslGitReadEnvironment) { - const optionalLocks = options.env?.GIT_OPTIONAL_LOCKS - return { - binary: 'wsl.exe', - args: [ - '-d', - wsl.distro, - '--exec', - '/usr/bin/env', - `PATH=${options.wslGitReadEnvironment.path}`, - `HOME=${options.wslGitReadEnvironment.home}`, - ...GIT_OUTPUT_LOCALE_ENV_ARGS, - ...(optionalLocks !== undefined ? [`GIT_OPTIONAL_LOCKS=${optionalLocks}`] : []), - options.wslGitReadEnvironment.gitPath, - ...(linuxCwd ? ['-C', linuxCwd] : []), - ...translatedArgs - ], - cwd: undefined, - wsl, - wslMode: 'direct-git' - } - } - - if (options.useWslLoginShell) { - // Why opt-in: the login shell is interactive for bash/zsh, so its rc output - // lands on stdout ahead of the payload. Callers that buffer the whole stream - // fence it; streaming consumers (git grep, ls-files -z) must not, because a - // marker would be glued onto their first record. - if (options.captureLoginShellOutput) { - const captured = buildWslCapturedLoginShellCommand(shellCmd) - return { - binary: 'wsl.exe', - args: buildWslExecArgs(wsl.distro, ['sh', '-lc', captured.command]), - cwd: undefined, - wsl, - wslMode: 'login-shell', - captured - } - } - return { - binary: 'wsl.exe', - args: buildWslExecArgs(wsl.distro, ['sh', '-lc', buildWslLoginShellCommand(shellCmd)]), - cwd: undefined, - wsl, - wslMode: 'login-shell' - } - } - - return { - binary: 'wsl.exe', - args: buildWslExecArgs(wsl.distro, ['bash', '-c', shellCmd]), - // Why: the `cd` inside bash -c handles the directory; a UNC cwd on the Node process is redundant and can break Node internals. - cwd: undefined, - wsl, - wslMode: 'non-login-shell' - } -} - -// ─── Git-specific runners ─────────────────────────────────────────── - -// Why: cap execFile output to prevent an uncatchable V8 string overflow; match relay MAX_GIT_BUFFER. -export const DEFAULT_GIT_MAX_BUFFER = 10 * 1024 * 1024 - -type GitExecOptions = { - cwd: string - encoding?: BufferEncoding | 'buffer' - maxBuffer?: number - timeout?: number - stdin?: string - env?: NodeJS.ProcessEnv - signal?: AbortSignal - wslDistro?: string - preferWslDirectGit?: boolean - useConfiguredSshCommandForNetwork?: boolean -} - -type CommandExecOptions = { - cwd?: string - encoding?: BufferEncoding - maxBuffer?: number - timeout?: number - env?: NodeJS.ProcessEnv - signal?: AbortSignal - wslDistro?: string -} - -function wslDistroForCommand(cwd: string | undefined, override?: string): string | null { - if (process.platform !== 'win32') { - return null - } - return (cwd ? parseWslPath(cwd)?.distro : undefined) ?? override ?? null -} - -function resolveGitCommand( - args: string[], - options: GitExecOptions, - forceLoginShell = false, - captureLoginShellOutput = false -): ResolvedCommand { - if (usesHostGitForWslLinkedWorktree(options.cwd, options.wslDistro)) { - // Why: WSL Git resolves a Windows-authored linked-worktree pointer relative to cwd. - return { binary: 'git', args, cwd: options.cwd, wsl: null, wslMode: null } - } - if (!forceLoginShell && shouldAttemptWslDirectGit(args, options)) { - const distro = wslDistroForCommand(options.cwd, options.wslDistro) - const environment = distro ? peekWslGitReadEnvironment(distro) : undefined - if (environment) { - return resolveCommand('git', args, options.cwd, options.wslDistro, { - wslGitReadEnvironment: environment, - env: options.env - }) - } - if (distro) { - void getWslGitReadEnvironment(distro) - } - } - return resolveGitCommandWithoutProbe(args, options, captureLoginShellOutput) -} - -function shouldAttemptWslDirectGit(args: string[], options: GitExecOptions): boolean { - return Boolean( - process.platform === 'win32' && - // Why either: callers can still opt in explicitly, but a plain read no - // longer has to -- it needs nothing the login shell provides, and routing - // it through one is what exposes callers to the shell's rc output. - (options.preferWslDirectGit || isWslDirectGitReadCommand(args)) && - !options.useConfiguredSshCommandForNetwork && - !Object.entries(options.env ?? {}).some( - ([key, value]) => - key.startsWith('GIT_') && key !== 'GIT_OPTIONAL_LOCKS' && value !== process.env[key] - ) && - options.wslDistro - ) -} - -function resolveGitCommandWithoutProbe( - args: string[], - options: GitExecOptions, - captureLoginShellOutput = false -): ResolvedCommand { - return resolveCommand('git', args, options.cwd, options.wslDistro, { - useWslLoginShell: Boolean(options.wslDistro), - captureLoginShellOutput - }) -} - -function isDirectWslGitNotFound(error: unknown, resolved: ResolvedCommand): boolean { - if (resolved.wslMode !== 'direct-git' || !error || typeof error !== 'object') { - return false - } - const { code, stderr } = error as { code?: unknown; stderr?: unknown } - const message = typeof stderr === 'string' ? stderr : String(stderr ?? '') - return code === 127 && (message.includes('not found') || message.includes('No such file')) -} - -function directWslGitExitCode(error: unknown, resolved: ResolvedCommand): number | null { - if (resolved.wslMode !== 'direct-git' || !error || typeof error !== 'object') { - return null - } - const code = (error as { code?: unknown }).code - return typeof code === 'number' ? code : null -} - -function invalidateMissingDirectWslGit(error: unknown, resolved: ResolvedCommand): boolean { - const isMissing = isDirectWslGitNotFound(error, resolved) - if (isMissing && resolved.wsl) { - invalidateWslGitReadEnvironment(resolved.wsl.distro) - } - return isMissing -} - -function disableDirectWslGitAfterSuccessfulFallback( - wasMissing: boolean, - resolved: ResolvedCommand -): void { - if (!wasMissing && resolved.wsl) { - disableWslGitReadEnvironment(resolved.wsl.distro) - } -} - -function isMissingCommandError(error: unknown): boolean { - return Boolean( - error && typeof error === 'object' && (error as { code?: unknown }).code === 'ENOENT' - ) -} - -function hasPathSeparator(command: string): boolean { - return command.includes('/') || command.includes('\\') -} - -function shouldRetryWindowsCommandShim(error: unknown, resolved: ResolvedCommand): boolean { - return ( - process.platform === 'win32' && - resolved.wsl === null && - isMissingCommandError(error) && - !hasPathSeparator(resolved.binary) && - !/\.[A-Za-z0-9]+$/.test(resolved.binary) - ) -} - -function createAbortError(): Error { - const error = new Error('The operation was aborted.') - error.name = 'AbortError' - return error -} - -const WINDOWS_TREE_KILL_WAIT_MS = 2_000 - -function killSpawnedCommandTree(child: ChildProcess): Promise { - const pid = child.pid - if (!pid || process.platform !== 'win32') { - child.kill() - return Promise.resolve() - } - return new Promise((resolve) => { - let killer: ChildProcess - try { - // Why: Windows shims/wsl.exe own descendants; wait for /t tree cleanup so a timed-out command can't outlive its probe. - killer = spawn('taskkill', ['/pid', String(pid), '/t', '/f'], { - stdio: 'ignore', - windowsHide: true - }) - if (!killer || typeof killer.unref !== 'function') { - child.kill() - resolve() - return - } - } catch { - child.kill() - resolve() - return - } - let settled = false - let timer: NodeJS.Timeout | null = null - const finish = (fallbackToChildKill: boolean): void => { - if (settled) { - return - } - settled = true - if (timer) { - clearTimeout(timer) - } - killer.removeAllListeners() - if (fallbackToChildKill) { - child.kill() - } - resolve() - } - killer.once('error', () => finish(true)) - killer.once('close', (code) => finish(code !== 0)) - timer = setTimeout(() => { - killer.kill() - finish(true) - }, WINDOWS_TREE_KILL_WAIT_MS) - killer.unref() - }) -} - -type ExecFileCaptureOptions = Omit & { - timeout?: number - stdin?: string -} - -function emptyExecFileOutput(options: ExecFileCaptureOptions): string | Buffer { - return options.encoding === 'buffer' ? Buffer.alloc(0) : '' -} - -function isExecFileResultObject( - value: unknown -): value is { stdout: string | Buffer; stderr: string | Buffer } { - return ( - value !== null && - typeof value === 'object' && - !Buffer.isBuffer(value) && - 'stdout' in value && - 'stderr' in value - ) -} - -function execFileCapture( - command: string, - args: string[], - options: ExecFileCaptureOptions -): Promise<{ stdout: string | Buffer; stderr: string | Buffer }> { - return new Promise((resolve, reject) => { - if (options.signal?.aborted) { - reject(createAbortError()) - return - } - - let settled = false - let terminating = false - let child: ChildProcess | null = null - let timer: NodeJS.Timeout | null = null - const cleanup = (): void => { - if (timer) { - clearTimeout(timer) - timer = null - } - options.signal?.removeEventListener('abort', onAbort) - } - const finish = ( - error: Error | null, - stdout: string | Buffer = emptyExecFileOutput(options), - stderr: string | Buffer = emptyExecFileOutput(options) - ): void => { - if (settled) { - return - } - settled = true - cleanup() - if (error) { - const enriched = error as Error & { stdout?: string | Buffer; stderr?: string | Buffer } - enriched.stdout ??= stdout - enriched.stderr ??= stderr - reject(enriched) - return - } - resolve({ stdout, stderr }) - } - const onAbort = (): void => { - if (settled || terminating) { - return - } - terminating = true - const abortError = createAbortError() - if (!child) { - terminating = false - finish(abortError) - return - } - void killSpawnedCommandTree(child).then(() => { - terminating = false - finish(abortError) - }) - } - - try { - const spawnStartedAt = performance.now() - // Why: our abort listener owns tree cleanup; Node's signal handler could kill wsl.exe before taskkill sees its children. - child = execFile( - command, - args, - { - cwd: options.cwd, - // Why: git.exe is console-subsystem and Orca's main process owns no - // console, so every spawn without this flashes a conhost that takes - // foreground. Git runs on nearly every interaction (#14543). - windowsHide: true, - encoding: options.encoding, - maxBuffer: options.maxBuffer ?? DEFAULT_GIT_MAX_BUFFER, - env: options.env - }, - (error, stdout, stderr) => { - if (terminating) { - return - } - if (!error && stderr === undefined && isExecFileResultObject(stdout)) { - finish(null, stdout.stdout, stdout.stderr) - return - } - finish(error, stdout, stderr) - } - ) - recordSubprocessSpawn(command, args, performance.now() - spawnStartedAt) - } catch (error) { - finish(error instanceof Error ? error : new Error(String(error))) - return - } - - child.once('error', (error) => { - if (!terminating) { - finish(error) - } - }) - - if (options.stdin !== undefined) { - endSubprocessStdin(child.stdin, options.stdin) - } - - // Why: Node's timeout waits forever on signal-ignoring CLIs; enforce our own deadline with bounded tree cleanup. - if (options.timeout && options.timeout > 0) { - timer = setTimeout(() => { - if (settled || terminating) { - return - } - terminating = true - const timeoutError = new Error(`${command} timed out.`) - if (!child) { - terminating = false - finish(timeoutError) - return - } - void killSpawnedCommandTree(child).then(() => { - terminating = false - finish(timeoutError) - }) - }, options.timeout) - } - options.signal?.addEventListener('abort', onAbort, { once: true }) - }) -} - -async function spawnCommandCapture( - command: string, - args: string[], - options: CommandExecOptions -): Promise<{ stdout: string; stderr: string }> { - const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(command, args) - return new Promise((resolve, reject) => { - if (options.signal?.aborted) { - reject(createAbortError()) - return - } - let settled = false - let stdout = '' - let stderr = '' - let stdoutBytes = 0 - let stderrBytes = 0 - const spawnStartedAt = performance.now() - const child = spawn(spawnCmd, spawnArgs, { - cwd: options.cwd, - env: options.env, - stdio: ['ignore', 'pipe', 'pipe'], - windowsHide: true - }) - recordSubprocessSpawn(spawnCmd, spawnArgs, performance.now() - spawnStartedAt) - let timer: NodeJS.Timeout | null = null - const onAbort = (): void => { - void killSpawnedCommandTree(child) - finish(createAbortError()) - } - const cleanupListeners = (): void => { - if (timer) { - clearTimeout(timer) - timer = null - } - options.signal?.removeEventListener('abort', onAbort) - child.stdout?.off('data', onStdoutData) - child.stderr?.off('data', onStderrData) - child.off('error', onError) - child.off('close', onClose) - } - const finish = (error: Error | null): void => { - if (settled) { - return - } - settled = true - cleanupListeners() - if (error) { - reject(Object.assign(error, { stdout, stderr })) - return - } - resolve({ stdout, stderr }) - } - timer = options.timeout - ? setTimeout(() => { - void killSpawnedCommandTree(child) - finish(new Error(`${command} timed out.`)) - }, options.timeout) - : null - options.signal?.addEventListener('abort', onAbort, { once: true }) - function onStdoutData(chunk: Buffer): void { - stdoutBytes += chunk.byteLength - if (options.maxBuffer && stdoutBytes > options.maxBuffer) { - void killSpawnedCommandTree(child) - finish(new Error(`${command} stdout exceeded maxBuffer.`)) - return - } - stdout += chunk.toString(options.encoding ?? 'utf-8') - } - function onStderrData(chunk: Buffer): void { - stderrBytes += chunk.byteLength - if (options.maxBuffer && stderrBytes > options.maxBuffer) { - void killSpawnedCommandTree(child) - finish(new Error(`${command} stderr exceeded maxBuffer.`)) - return - } - stderr += chunk.toString(options.encoding ?? 'utf-8') - } - function onError(error: Error): void { - finish(error) - } - function onClose(code: number | null): void { - if (code === 0) { - finish(null) - return - } - finish(new Error(`${command} exited with ${code}.`)) - } - child.stdout?.on('data', onStdoutData) - child.stderr?.on('data', onStderrData) - child.on('error', onError) - child.on('close', onClose) - }) -} - -export function gitOptionalLocksDisabledEnv( - env: NodeJS.ProcessEnv = process.env -): NodeJS.ProcessEnv { - return { - ...env, - GIT_OPTIONAL_LOCKS: '0' - } -} - -/** - * Append git config via the GIT_CONFIG_COUNT/KEY_n/VALUE_n env protocol (git >= 2.31), - * composing with any count already in `env` so we never clobber a caller's config. - */ -export { appendGitConfigEnv } - -/** - * Pin Orca-spawned git to untranslated English output so stderr/progress parsers - * work under any user locale (issue #7808). Terminal git is untouched. - */ -export function untranslatedGitOutputEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { - return { ...env, ...UNTRANSLATED_GIT_OUTPUT_ENV } -} - -export function promptGuardGitEnv( - env: NodeJS.ProcessEnv = process.env, - platform: NodeJS.Platform = process.platform -): NodeJS.ProcessEnv { - return gitCredentialPromptGuardEnv(untranslatedGitOutputEnv(env), platform) -} - -/** - * Credential-prompt guard for a general-purpose shell (PTYs, hook scripts): - * like promptGuardGitEnv but without the issue-7808 locale pins, which would - * change the locale of every child process, not just git's. - */ -export function promptGuardShellEnv( - env: NodeJS.ProcessEnv = process.env, - platform: NodeJS.Platform = process.platform -): NodeJS.ProcessEnv { - return gitCredentialPromptGuardEnv(env, platform) -} - -/** - * Force git non-interactive so it fails fast instead of hanging on a prompt with - * no terminal to answer it; on headless `serve` those stuck calls wedge every - * client (issue #5308). - * - * - GIT_TERMINAL_PROMPT=0: git errors instead of prompting for credentials. - * - GIT_ASKPASS / SSH_ASKPASS: emptied when unset so no GUI helper blocks; a - * caller-provided askpass is preserved (custom setups serve creds non-interactively). - * - GIT_SSH_COMMAND BatchMode=yes: SSH errors instead of prompting (doesn't change - * host trust); only added when the caller hasn't set its own. - */ -export function nonInteractiveGitEnv( - env: NodeJS.ProcessEnv = process.env, - platform: NodeJS.Platform = process.platform -): NodeJS.ProcessEnv { - const next = promptGuardGitEnv(env, platform) - if (!next.GIT_SSH_COMMAND) { - next.GIT_SSH_COMMAND = 'ssh -o BatchMode=yes' - if (platform === 'win32') { - // Why: forward GIT_SSH_COMMAND to WSL only when we set it — a caller's Windows-specific value must not leak into Linux git. - addWslEnvKeys(next, ['GIT_SSH_COMMAND']) - } - } - return next -} - -type GitSshPolicyMode = - | 'default' - | 'explicit-env' - | 'fallback' - | 'configured-openssh' - | 'configured-wrapper-passthrough' - -const CORE_SSH_COMMAND_PROBE_TIMEOUT_MS = 2500 - -function commandBasename(command: string): string { - const pieces = command.split(/[\\/]+/) - return pieces.at(-1)?.toLowerCase() ?? command.toLowerCase() -} - -function isMergeableOpenSshCommand(command: string): boolean { - const basename = commandBasename(command) - return basename === 'ssh' || basename === 'ssh.exe' -} - -function shellTokenize(command: string): string[] | null { - const tokens: string[] = [] - let current = '' - let quote: "'" | '"' | null = null - let escaped = false - - for (let i = 0; i < command.length; i++) { - const char = command[i] - if (escaped) { - current += char - escaped = false - continue - } - if (char === '\\') { - const next = command[i + 1] - if (next && /[\s'"\\]/.test(next)) { - escaped = true - } else { - current += char - } - continue - } - if (quote) { - if (char === quote) { - quote = null - } else { - current += char - } - continue - } - if (char === "'" || char === '"') { - quote = char - continue - } - if (/\s/.test(char)) { - if (current) { - tokens.push(current) - current = '' - } - continue - } - if (';&|<>()`'.includes(char)) { - return null - } - current += char - } - - if (escaped || quote) { - return null - } - if (current) { - tokens.push(current) - } - return tokens -} - -function shellQuoteToken(token: string): string { - return /^[A-Za-z0-9_@%+=:,./~-]+$/.test(token) ? token : quotePosixShell(token) -} - -function containsShellExpansionSyntax(command: string): boolean { - return command.includes('$') -} - -function withoutBatchModeOptions(tokens: string[]): string[] { - const next: string[] = [] - for (let i = 0; i < tokens.length; i++) { - const token = tokens[i] - const lower = token.toLowerCase() - if (lower === '-o') { - const option = tokens[i + 1]?.toLowerCase() - if (option?.startsWith('batchmode')) { - i += 1 - continue - } - } - if (lower.startsWith('-obatchmode')) { - continue - } - next.push(token) - } - return next -} - -function buildOpenSshBatchModeCommand(configuredCommand: string): string | null { - if (containsShellExpansionSyntax(configuredCommand)) { - return null - } - const tokens = shellTokenize(configuredCommand) - if (!tokens || tokens.length === 0 || !isMergeableOpenSshCommand(tokens[0])) { - return null - } - return [...withoutBatchModeOptions(tokens), '-o', 'BatchMode=yes'].map(shellQuoteToken).join(' ') -} - -async function buildNetworkSshPolicyEnv(options: GitExecOptions): Promise<{ - env: NodeJS.ProcessEnv - mode: GitSshPolicyMode -}> { - const promptEnv = promptGuardGitEnv(options.env) - if (promptEnv.GIT_SSH_COMMAND) { - return { env: promptEnv, mode: 'explicit-env' } - } - - // Why fenced: a login-shell banner here reads as a user-configured sshCommand, - // which skips the BatchMode fallback below and disarms the no-prompt guard. - const resolved = resolveGitCommand(['config', '--get', 'core.sshCommand'], options, true, true) - let configuredCommand = '' - try { - const { stdout } = await execFileCapture(resolved.binary, resolved.args, { - cwd: resolved.cwd, - encoding: 'utf-8', - maxBuffer: DEFAULT_GIT_MAX_BUFFER, - timeout: CORE_SSH_COMMAND_PROBE_TIMEOUT_MS, - env: promptEnv, - signal: options.signal - }) - const payload = resolved.captured?.readStdout(String(stdout)) ?? String(stdout) - configuredCommand = payload.trim() - } catch { - configuredCommand = '' - } - - if (!configuredCommand) { - const env = { ...promptEnv, GIT_SSH_COMMAND: 'ssh -o BatchMode=yes' } - // Why: WSL routing can come from either an explicit distro or a UNC cwd. - if (resolved.wsl) { - addWslEnvKeys(env, ['GIT_SSH_COMMAND']) - } - return { env, mode: 'fallback' } - } - - const batchModeCommand = buildOpenSshBatchModeCommand(configuredCommand) - if (!batchModeCommand) { - // Why: custom SSH wrappers are user policy; rewriting their argv is riskier than relying on prompt guards + timeout. - return { env: promptEnv, mode: 'configured-wrapper-passthrough' } - } - - const env = { ...promptEnv, GIT_SSH_COMMAND: batchModeCommand } - if (resolved.wsl) { - addWslEnvKeys(env, ['GIT_SSH_COMMAND']) - } - return { env, mode: 'configured-openssh' } -} - /** * Async git command execution. Drop-in replacement for * `execFileAsync('git', args, { cwd, encoding, ... })`. @@ -1231,755 +205,6 @@ function readCapturedGitBuffer(stdout: Buffer, resolved: ResolvedCommand): Buffe return endIndex === -1 ? stdout.subarray(payloadStart) : stdout.subarray(payloadStart, endIndex) } -/** Result of a streamed git command; `stoppedEarly` is true when onStdout asked to stop before the child exited. */ -export type GitStreamResult = { stoppedEarly: boolean } - -type GitStreamOptions = { - cwd: string - env?: NodeJS.ProcessEnv - wslDistro?: string - preferWslDirectGit?: boolean - signal?: AbortSignal - /** Byte backstop; defaults to DEFAULT_GIT_MAX_BUFFER. */ - maxBuffer?: number - /** - * Called for each decoded stdout chunk. Return true to stop: the child is - * killed and the promise resolves with stoppedEarly=true. - */ - onStdout: (chunk: string) => boolean | void -} - -/** - * Stream a git command's stdout incrementally instead of buffering it whole. - * - * Why: output larger than V8's max string (e.g. status on a repo with a huge - * un-ignored folder) crashes the process when buffered; streaming keeps memory - * bounded and lets the parser stop git early. Built on gitSpawn for WSL routing. - */ -export async function gitStreamStdout( - args: string[], - options: GitStreamOptions -): Promise { - const maxBuffer = options.maxBuffer ?? DEFAULT_GIT_MAX_BUFFER - return withGitSpan({ args, cwd: options.cwd }, async () => { - if (isWslLinkedWorktreeGitRoutingCandidate(options.cwd, options.wslDistro)) { - await prepareWslLinkedWorktreeGitRouting(options.cwd, options.wslDistro, { - signal: options.signal - }) - } - const gitOptions: GitExecOptions = { - cwd: options.cwd, - ...(options.env ? { env: options.env } : {}), - ...(options.wslDistro ? { wslDistro: options.wslDistro } : {}), - ...(options.preferWslDirectGit ? { preferWslDirectGit: true } : {}), - ...(options.signal ? { signal: options.signal } : {}) - } - let resolved = resolveGitCommand(args, gitOptions) - const environmentReady = prepareWindowsHostGitEnvironment( - resolved, - gitOptions.env, - options.signal - ) - if (environmentReady) { - gitOptions.env = await environmentReady - } - resolved = resolveGitCommand(args, gitOptions) - const stream = (command: ResolvedCommand): Promise => - new Promise((resolve, reject) => { - if (options.signal?.aborted) { - reject(createAbortError()) - return - } - const stdio: SpawnOptions['stdio'] = ['ignore', 'pipe', 'pipe'] - const spawnOptions = { - cwd: options.cwd, - env: nonInteractiveGitEnv(gitOptions.env), - stdio, - wslDistro: options.wslDistro, - windowsHide: true - } - let child: ChildProcess - if (command.wslMode === 'direct-git') { - const spawnStartedAt = performance.now() - child = spawn(command.binary, command.args, { - cwd: command.cwd, - env: untranslatedGitOutputEnv(spawnOptions.env), - stdio: spawnOptions.stdio, - windowsHide: true - }) - recordSubprocessSpawn(command.binary, command.args, performance.now() - spawnStartedAt) - } else { - child = gitSpawn(args, spawnOptions) - } - - let settled = false - let stoppedEarly = false - let stdoutBytes = 0 - let stderr = '' - let stderrBytes = 0 - // Why: decode statefully so a multibyte UTF-8 char split across chunks isn't corrupted into replacement chars. - const stdoutDecoder = new StringDecoder('utf8') - const stderrDecoder = new StringDecoder('utf8') - - const cleanup = (): void => { - child.stdout?.off('data', onStdoutData) - child.stderr?.off('data', onStderrData) - child.off('error', onError) - child.off('close', onClose) - options.signal?.removeEventListener('abort', onAbort) - // Flush any bytes the decoders were holding for an incomplete sequence. - stdoutDecoder.end() - stderrDecoder.end() - } - const finish = (error: Error | null): void => { - if (settled) { - return - } - settled = true - cleanup() - if (error) { - reject(Object.assign(error, { stderr, stdoutBytes })) - return - } - resolve({ stoppedEarly }) - } - - function onStdoutData(chunk: Buffer): void { - stdoutBytes += chunk.byteLength - if (stdoutBytes > maxBuffer) { - void killSpawnedCommandTree(child) - finish(new Error('git stdout exceeded maxBuffer.')) - return - } - const decoded = stdoutDecoder.write(chunk) - if (decoded.length === 0) { - return - } - // Why: a throw from the caller's parser would escape this event handler and crash main; convert to a rejection. - let shouldStop: boolean | void - try { - shouldStop = options.onStdout(decoded) - } catch (error) { - void killSpawnedCommandTree(child) - finish(error instanceof Error ? error : new Error(String(error))) - return - } - if (shouldStop === true) { - // Parser hit its limit: kill git and resolve cleanly with the partial output. - stoppedEarly = true - void killSpawnedCommandTree(child) - finish(null) - } - } - function onStderrData(chunk: Buffer): void { - stderrBytes += chunk.byteLength - if (stderrBytes > maxBuffer) { - void killSpawnedCommandTree(child) - finish(new Error('git stderr exceeded maxBuffer.')) - return - } - stderr += stderrDecoder.write(chunk) - } - function onError(error: Error): void { - finish(error) - } - function onClose(code: number | null): void { - if (stoppedEarly || code === 0) { - finish(null) - return - } - finish(Object.assign(new Error(`git exited with ${code}: ${stderr}`), { code })) - } - function onAbort(): void { - if (!child.pid) { - // Why: failed spawn reports ENOENT after abort cleanup; retain a listener so it cannot crash main. - child.once('error', () => {}) - } - void killSpawnedCommandTree(child) - finish(createAbortError()) - } - - child.stdout?.on('data', onStdoutData) - child.stderr?.on('data', onStderrData) - child.on('error', onError) - child.on('close', onClose) - options.signal?.addEventListener('abort', onAbort, { once: true }) - if (options.signal?.aborted) { - onAbort() - } - }) - try { - return await stream(resolved) - } catch (error) { - const stdoutBytes = - error && typeof error === 'object' ? (error as { stdoutBytes?: unknown }).stdoutBytes : null - if ( - stdoutBytes === 0 && - directWslGitExitCode(error, resolved) !== null && - !options.signal?.aborted - ) { - const wasMissing = invalidateMissingDirectWslGit(error, resolved) - resolved = resolveGitCommandWithoutProbe(args, gitOptions) - const result = await stream(resolved) - disableDirectWslGitAfterSuccessfulFallback(wasMissing, resolved) - return result - } - throw error - } - }) -} - -// Why: sync git blocks the main thread; a dead network drive can hang git for minutes without a timeout (issue #7225's 127s freeze). -const GIT_EXEC_SYNC_TIMEOUT_MS = 15_000 - -/** - * Sync git command execution. Drop-in replacement for - * `execFileSync('git', args, { cwd, encoding, ... })`. - * - * Returns trimmed stdout as a string. - */ -export function gitExecFileSync( - args: string[], - options: { - cwd: string - encoding?: BufferEncoding - stdio?: SpawnOptions['stdio'] - timeout?: number - } -): string { - const resolved = resolveCommand('git', args, options.cwd) - const spawnStartedAt = performance.now() - try { - return execFileSync(resolved.binary, resolved.args, { - cwd: resolved.cwd, - encoding: options.encoding ?? 'utf-8', - env: untranslatedGitOutputEnv(), - stdio: options.stdio ?? ['pipe', 'pipe', 'pipe'], - timeout: options.timeout ?? GIT_EXEC_SYNC_TIMEOUT_MS, - windowsHide: true - }) as string - } finally { - // Sync exec blocks the main thread for its whole duration — the cost issue #7576 flags. - recordSubprocessSpawn(resolved.binary, resolved.args, performance.now() - spawnStartedAt) - } -} - -/** - * Spawn a git child process. Drop-in replacement for - * `spawn('git', args, { cwd, stdio, ... })`. - */ -type GitSpawnOptions = SpawnOptions & { cwd: string; wslDistro?: string } - -export async function gitSpawnAfterWindowsEnvironmentReady( - args: string[], - options: GitSpawnOptions -): Promise { - if (options.signal?.aborted) { - throw createAbortError() - } - const resolved = resolveGitCommand(args, { - cwd: options.cwd, - ...(options.wslDistro ? { wslDistro: options.wslDistro } : {}), - ...(options.env ? { env: options.env } : {}) - }) - const env = await (prepareWindowsHostGitEnvironment(resolved, options.env, options.signal) ?? - options.env) - if (options.signal?.aborted) { - throw createAbortError() - } - return gitSpawn(args, env === options.env ? options : { ...options, env }) -} - -export function gitSpawn(args: string[], options: GitSpawnOptions): ChildProcess { - const { wslDistro, ...spawnOptions } = options - const resolved = resolveGitCommand(args, { - cwd: options.cwd, - ...(wslDistro ? { wslDistro } : {}), - ...(spawnOptions.env ? { env: spawnOptions.env } : {}) - }) - const spawnStartedAt = performance.now() - const child = spawn(resolved.binary, resolved.args, { - ...spawnOptions, - env: untranslatedGitOutputEnv(spawnOptions.env ?? process.env), - windowsHide: true, - cwd: resolved.cwd - }) - recordSubprocessSpawn(resolved.binary, resolved.args, performance.now() - spawnStartedAt) - return child -} - -// ─── gh CLI runners ───────────────────────────────────────────────── - -// `cwd?` omitted for non-repo-scoped gh calls (rate_limit, listAccessibleProjects) so one WSL-aware wrapper serves both. -// `wslDistro?` routes global cwd-less gh through `wsl.exe -d ` on WSL-only Windows where gh.exe isn't on host PATH. -// `idempotent?` gates transient-error retry (auto-detected from argv); retrying a write that already reached GitHub would duplicate it. -type GhExecOptions = Omit & { - cwd?: string - wslDistro?: string - idempotent?: boolean - // Why: `gh api` and `--repo OWNER/REPO` shorthand resolve against gh's - // default host, not the repo's remote. Carrying the host here lets the - // runner qualify every spawn once, so call sites can't silently fall back - // to github.com for GHES repos; it also scopes the rate-limit breaker. - host?: string -} - -const NON_IDEMPOTENT_METHODS = new Set(['POST', 'PATCH', 'PUT', 'DELETE']) -// `gh ` write subcommands; reads are absent on purpose so they keep retrying. -const NON_IDEMPOTENT_GH_VERBS = new Set([ - 'create', - 'edit', - 'update', - 'delete', - 'close', - 'reopen', - 'merge', - 'comment', - 'review', - 'ready', - 'lock', - 'unlock', - 'pin', - 'unpin', - 'transfer', - 'develop' -]) - -function argsLookIdempotent(args: string[]): boolean { - let explicitMethodSeen = false - let hasApiBodyField = false - let hasGraphQlQuery = false - const isGraphQlApi = args[0] === 'api' && args[1] === 'graphql' - for (let i = 0; i < args.length; i++) { - const a = args[i] - if (a === '-X' || a === '--method') { - explicitMethodSeen = true - const next = args[i + 1] - if (typeof next === 'string' && NON_IDEMPOTENT_METHODS.has(next.toUpperCase())) { - return false - } - } - // Single-token form `--method=POST` (gh accepts this). - if (a.startsWith('--method=')) { - explicitMethodSeen = true - const value = a.slice('--method='.length) - if (NON_IDEMPOTENT_METHODS.has(value.toUpperCase())) { - return false - } - } - // `gh api` auto-POSTs when -f/-F/--field body fields are given without -X; track them. - if (a === '-f' || a === '-F' || a === '--field' || a === '--raw-field') { - hasApiBodyField = true - } else if ( - a.startsWith('-f=') || - a.startsWith('-F=') || - a.startsWith('--field=') || - a.startsWith('--raw-field=') - ) { - hasApiBodyField = true - } - // Detect GraphQL `query=mutation(…)` so endpoint writes also fail fast on transient errors. - if (a.startsWith('query=')) { - hasGraphQlQuery = true - const trimmed = a.slice('query='.length).trimStart().toLowerCase() - if (trimmed.startsWith('mutation')) { - return false - } - } - } - // `gh api -f foo=bar` with no -X auto-POSTs → non-idempotent; GraphQL query bodies are the exception (still reads). - if ( - args[0] === 'api' && - hasApiBodyField && - !explicitMethodSeen && - !(isGraphQlApi && hasGraphQlQuery) - ) { - return false - } - // `gh ` writes (args[1]); `gh api` without -X defaults to idempotent GET, so it's excluded here. - if (args.length >= 2 && args[0] !== 'api') { - if (NON_IDEMPOTENT_GH_VERBS.has(args[1])) { - return false - } - } - return true -} - -/** - * Classify whether a gh execFile rejection is worth retrying. - * - * Why: gh surfaces HTTP status as stderr substrings ("HTTP 504", econnreset, …). - * Retry 5xx/network resets and 429 only without Retry-After (propagate those so - * the UI can show the wait); primary-rate-limit 403 is never transient. - */ -export function isTransientGhError(stderr: string): boolean { - const s = stderr.toLowerCase() - if ( - s.includes('http 500') || - s.includes('http 502') || - s.includes('http 503') || - s.includes('http 504') || - s.includes('econnreset') || - s.includes('etimedout') || - s.includes('socket hang up') - ) { - return true - } - // 429 without Retry-After: retry. With Retry-After: propagate. - if (s.includes('http 429')) { - return parseRetryAfterMs(stderr) === null - } - return false -} - -// Why: 3 attempts total (250ms → 1s backoff); array length defines retry count (total attempts = length + 1). -const GH_RETRY_DELAYS_MS = [250, 1000] as const - -// Why: Retry-After is unbounded and untrusted; cap at 30s so a gh call can't block the IPC thread indefinitely. -const GH_RETRY_AFTER_MAX_MS = 30_000 -const DEFAULT_GH_EXEC_TIMEOUT_MS = 30_000 - -async function sleep(ms: number, signal?: AbortSignal): Promise { - if (signal?.aborted) { - throw createAbortError() - } - await new Promise((resolve, reject) => { - const timer = setTimeout(finish, ms) - const onAbort = (): void => finish(createAbortError()) - function finish(error?: Error): void { - clearTimeout(timer) - signal?.removeEventListener('abort', onAbort) - if (error) { - reject(error) - } else { - resolve() - } - } - signal?.addEventListener('abort', onAbort, { once: true }) - }) -} - -function defaultGhExecTimeoutMs(env: NodeJS.ProcessEnv = process.env): number { - const raw = env.ORCA_GH_EXEC_TIMEOUT_MS - if (!raw) { - return DEFAULT_GH_EXEC_TIMEOUT_MS - } - const parsed = Number(raw) - return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_GH_EXEC_TIMEOUT_MS -} - -function nonInteractiveGhEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { - return { - ...env, - GH_PROMPT_DISABLED: env.GH_PROMPT_DISABLED ?? '1' - } -} - -function hasGhHostnameFlag(args: readonly string[]): boolean { - return args.some((arg) => arg === '--hostname' || arg.startsWith('--hostname=')) -} - -function hostQualifiedGhRepoValue(value: string, host: string): string { - // URLs and already-qualified HOST/OWNER/REPO values pass through untouched. - if (value.includes('://') || value.split('/').length !== 2) { - return value - } - return `${host}/${value}` -} - -/** - * Host-qualify a gh invocation from `options.host`: `--hostname` for `api` - * calls, `HOST/OWNER/REPO` for `--repo`/`-R` shorthand. SSH-backed repos run - * gh with no cwd, so this is their only host signal (#8312). - * - * @internal exported for tests. - */ -export function applyGhHostToArgs(args: string[], host?: string): string[] { - if (!host) { - return args - } - let result = args - if (result[0] === 'api' && !hasGhHostnameFlag(result)) { - result = ['api', '--hostname', host, ...result.slice(1)] - } - // Why: bare OWNER/REPO shorthand resolves against gh's default host — GH_HOST - // when set — so github.com must be qualified too, not just GHES, or a - // process-level GH_HOST redirects pinned github.com commands. - // Combined short forms (`-Ra/b`, `-R=a/b`) are deliberately not rewritten: - // no call site uses them, and prefix-matching `-R` corrupts free-text values - // of other flags (e.g. a --title that happens to start with `-R`). - const qualified: string[] = [] - for (let i = 0; i < result.length; i += 1) { - const arg = result[i] - if (arg === '--repo' || arg === '-R') { - qualified.push(arg) - const value = result[i + 1] - if (value !== undefined) { - qualified.push(hostQualifiedGhRepoValue(value, host)) - i += 1 - } - continue - } - if (arg.startsWith('--repo=')) { - qualified.push(`--repo=${hostQualifiedGhRepoValue(arg.slice('--repo='.length), host)}`) - continue - } - qualified.push(arg) - } - return qualified -} - -function explicitGhHostname(args: readonly string[]): string | undefined { - for (let i = 0; i < args.length; i += 1) { - if (args[i] === '--hostname') { - const value = args[i + 1]?.trim() - return value || undefined - } - if (args[i].startsWith('--hostname=')) { - const value = args[i].slice('--hostname='.length).trim() - return value || undefined - } - } - return undefined -} - -function explicitGhRepoHostname(args: readonly string[]): string | undefined { - for (let i = 0; i < args.length; i += 1) { - let value: string | undefined - if (args[i] === '--repo' || args[i] === '-R') { - value = args[i + 1] - } else if (args[i].startsWith('--repo=')) { - value = args[i].slice('--repo='.length) - } - const parts = value?.trim().split('/') - if (parts?.length === 3 && parts.every(Boolean)) { - return parts[0] - } - } - return undefined -} - -function ghRateLimitScope( - args: readonly string[], - options: GhExecOptions, - resolved: ResolvedCommand -): string { - const runtime = resolved.wsl ? `wsl:${resolved.wsl.distro.toLowerCase()}` : 'native' - // Why: an explicit argv hostname controls the actual gh request even when - // GH_HOST or options.host disagree, so breaker state must follow that host. - const host = - explicitGhHostname(args) ?? - options.host ?? - explicitGhRepoHostname(args) ?? - options.env?.GH_HOST ?? - process.env.GH_HOST ?? - 'github.com' - return ghRateLimitScopeKey(runtime, host) -} - -function assertGhRateLimitScopeAvailable( - args: readonly string[], - options: GhExecOptions, - resolved: ResolvedCommand, - bucket: GhRateLimitBucket, - exemptProbe: boolean -): void { - if (exemptProbe) { - return - } - const blockedUntilMs = getGhRateLimitBlockedUntilMs( - bucket, - Date.now(), - ghRateLimitScope(args, options, resolved) - ) - if (blockedUntilMs !== null) { - throw createGhRateLimitBlockedError(bucket, blockedUntilMs) - } -} - -/** - * Async gh CLI execution. Drop-in replacement for - * `execFileAsync('gh', args, { cwd, encoding, ... })`. - * - * Retries transient 5xx / 429-without-Retry-After / network-reset failures with - * exponential backoff; other errors fail fast. - */ -export async function ghExecFileAsync( - args: string[], - options: GhExecOptions = {} -): Promise<{ stdout: string; stderr: string }> { - // Why: retry safety must reflect the original call even when fallbacks replace the resolved command. - const idempotent = options.idempotent ?? argsLookIdempotent(args) - args = applyGhHostToArgs(args, options.host) - let resolved = resolveCommand('gh', args, options.cwd, options.wslDistro) - // Why: while a bucket is rate-limited every spawn returns 403 — fail fast; the probe is exempt so the breaker can learn the reset. - // Why: scope by runtime and host so unrelated github.com, GHES, and WSL quotas cannot block each other. - const rateLimitBucket = classifyGhRateLimitBucket(args) - const rateLimitProbe = isGhRateLimitProbe(args) - assertGhRateLimitScopeAvailable(args, options, resolved, rateLimitBucket, rateLimitProbe) - let lastError: unknown - let attemptedHostFallback = false - let attemptedDefaultWslFallback = false - for (let attempt = 0; attempt <= GH_RETRY_DELAYS_MS.length; attempt++) { - try { - const { stdout, stderr } = await execFileCapture(resolved.binary, resolved.args, { - cwd: resolved.cwd, - encoding: (options.encoding ?? 'utf-8') as BufferEncoding, - maxBuffer: options.maxBuffer, - // Why: bound gh so one stuck child fails visibly instead of wedging the IPC lane. - timeout: options.timeout ?? defaultGhExecTimeoutMs(options.env), - env: nonInteractiveGhEnv(options.env), - signal: options.signal - }) - return { stdout: stdout as string, stderr: stderr as string } - } catch (err) { - lastError = err - const { stderr } = extractExecError(err) - if (isGhPrimaryRateLimitStderr(stderr)) { - notifyGhPrimaryRateLimit(rateLimitBucket, ghRateLimitScope(args, options, resolved)) - } - if ( - process.platform === 'win32' && - !attemptedDefaultWslFallback && - resolved.wsl === null && - !options.cwd && - !options.wslDistro && - isHostCommandMissing(err, 'gh') - ) { - const wslResolved = resolveDefaultWslCli('gh', args) - if (wslResolved) { - // Why: WSL-only Windows installs have no host gh.exe, and global calls (rate_limit/auth) carry no cwd to route by. - resolved = wslResolved - attemptedDefaultWslFallback = true - assertGhRateLimitScopeAvailable(args, options, resolved, rateLimitBucket, rateLimitProbe) - attempt = -1 - continue - } - } - if (!attemptedHostFallback && canFallBackToHostGitHubCli('gh', args, resolved, stderr)) { - resolved = resolveHostGitHubCli('gh', args) - attemptedHostFallback = true - assertGhRateLimitScopeAvailable(args, options, resolved, rateLimitBucket, rateLimitProbe) - attempt = -1 - continue - } - const isLastAttempt = attempt >= GH_RETRY_DELAYS_MS.length - if (idempotent && !isLastAttempt && isTransientGhError(stderr)) { - // Why: honor the server's Retry-After over our backoff (a shorter sleep just re-fails); cap so a huge hint can't stall IPC. - const retryAfterMs = parseRetryAfterMs(stderr) - const delayMs = - retryAfterMs !== null - ? Math.min(retryAfterMs, GH_RETRY_AFTER_MAX_MS) - : GH_RETRY_DELAYS_MS[attempt] - await sleep(delayMs, options.signal) - continue - } - throw err - } - } - // Unreachable: the loop either returns or throws. Here for TS exhaustiveness. - throw lastError -} - -// ─── glab CLI runner ──────────────────────────────────────────────── -// Why: cloned from the gh runner rather than abstracted behind a generic runner, to avoid touching the working gh path. - -type GlabExecOptions = Omit & { - cwd?: string - wslDistro?: string - idempotent?: boolean - allowDefaultWslFallback?: boolean -} - -/** Async glab CLI execution; drop-in for execFileAsync('glab', …). Retry policy mirrors ghExecFileAsync. */ -/** - * glab's `--hostname` rejects host:port, so a ported self-hosted GitLab must use the GITLAB_HOST env var instead — translate it. - * @internal exported for tests. - */ -export function redirectPortedHostnameToEnv( - args: string[], - options: GlabExecOptions -): { args: string[]; options: GlabExecOptions } { - const i = args.indexOf('--hostname') - if (i === -1 || i + 1 >= args.length) { - return { args, options } - } - const host = args[i + 1] - if (!/^[^/\s]+:\d+$/.test(host)) { - return { args, options } - } - // Why WSLENV: a glab routed into a distro only sees Windows-side variables - // named in WSLENV, so without this the ported host silently never crosses and - // glab talks to gitlab.com instead (#12557). Credit: #12558. - const env: NodeJS.ProcessEnv = { ...(options.env ?? process.env), GITLAB_HOST: host } - // Unguarded by platform on purpose: WSLENV is meaningless outside Windows, so - // the extra key is inert there, and gating it would need the test to know the - // platform for no behavioural gain. - addWslEnvKeys(env, ['GITLAB_HOST']) - return { - args: [...args.slice(0, i), ...args.slice(i + 2)], - options: { ...options, env } - } -} - -export async function glabExecFileAsync( - args: string[], - options: GlabExecOptions = {} -): Promise<{ stdout: string; stderr: string }> { - ;({ args, options } = redirectPortedHostnameToEnv(args, options)) - let resolved = resolveCommand('glab', args, options.cwd, options.wslDistro) - let lastError: unknown - let attemptedDefaultWslFallback = false - for (let attempt = 0; attempt <= GH_RETRY_DELAYS_MS.length; attempt++) { - try { - const { stdout, stderr } = await execFileCapture(resolved.binary, resolved.args, { - cwd: resolved.cwd, - encoding: (options.encoding ?? 'utf-8') as BufferEncoding, - maxBuffer: options.maxBuffer, - timeout: options.timeout, - env: options.env, - signal: options.signal - }) - return { stdout: stdout as string, stderr: stderr as string } - } catch (err) { - lastError = err - const { stderr } = extractExecError(err) - if ( - process.platform === 'win32' && - !attemptedDefaultWslFallback && - resolved.wsl === null && - !options.cwd && - !options.wslDistro && - options.allowDefaultWslFallback !== false && - isHostCommandMissing(err, 'glab') - ) { - const wslResolved = resolveDefaultWslCli('glab', args) - if (wslResolved) { - // Why: mirror gh's WSL-only fallback for global GitLab project/auth calls. - resolved = wslResolved - attemptedDefaultWslFallback = true - attempt = -1 - continue - } - } - const isLastAttempt = attempt >= GH_RETRY_DELAYS_MS.length - // Why: mirror gh's write-safety gate — don't auto-retry a non-idempotent write that GitLab may already have applied. - const idempotent = options.idempotent ?? argsLookIdempotent(args) - if (idempotent && !isLastAttempt && isTransientGhError(stderr)) { - const retryAfterMs = parseRetryAfterMs(stderr) - const delayMs = - retryAfterMs !== null - ? Math.min(retryAfterMs, GH_RETRY_AFTER_MAX_MS) - : GH_RETRY_DELAYS_MS[attempt] - await sleep(delayMs) - continue - } - throw err - } - } - throw lastError -} - // ─── Generic command runner (for rg, etc.) ────────────────────────── /** @@ -1995,13 +220,14 @@ export function wslAwareSpawn( const resolved = resolveCommand(command, args, options.cwd, wslDistro, { useWslLoginShell }) + const binary = resolved.wsl ? 'wsl.exe' : resolved.binary const spawnStartedAt = performance.now() - const child = spawn(resolved.binary, resolved.args, { + const child = spawn(binary, resolved.args, { ...spawnOptions, windowsHide: true, cwd: resolved.cwd }) - recordSubprocessSpawn(resolved.binary, resolved.args, performance.now() - spawnStartedAt) + recordSubprocessSpawn(binary, resolved.args, performance.now() - spawnStartedAt) return child } @@ -2027,6 +253,3 @@ export function translateWslOutputPaths( toWindowsWslPath(linuxPath, distro) ) } - -/** Convenience re-export of wsl.ts path helpers so consumers don't import it directly. */ -export { parseWslPath, toLinuxPath, toWindowsWslPath, isWslPath } from '../wsl' diff --git a/src/main/git/spawn-command-capture.ts b/src/main/git/spawn-command-capture.ts new file mode 100644 index 00000000000..b2f65fc82d8 --- /dev/null +++ b/src/main/git/spawn-command-capture.ts @@ -0,0 +1,127 @@ +import { spawnNodeProcess as spawn } from '../../shared/child-process/node-process-execution' +import { recordSubprocessSpawn } from '../diagnostics/main-thread-churn-probe' +import { getSpawnArgsForWindows } from '../win32-utils' +import type { ResolvedCommand } from './command-resolution' +import { createAbortError, killSpawnedCommandTree } from './subprocess-lifecycle' + +export type CommandExecOptions = { + cwd?: string + encoding?: BufferEncoding + maxBuffer?: number + timeout?: number + env?: NodeJS.ProcessEnv + signal?: AbortSignal + wslDistro?: string +} +function isMissingCommandError(error: unknown): boolean { + return Boolean( + error && typeof error === 'object' && (error as { code?: unknown }).code === 'ENOENT' + ) +} + +function hasPathSeparator(command: string): boolean { + return command.includes('/') || command.includes('\\') +} + +export function shouldRetryWindowsCommandShim(error: unknown, resolved: ResolvedCommand): boolean { + return ( + process.platform === 'win32' && + resolved.wsl === null && + isMissingCommandError(error) && + !hasPathSeparator(resolved.binary) && + !/\.[A-Za-z0-9]+$/.test(resolved.binary) + ) +} +export async function spawnCommandCapture( + command: string, + args: string[], + options: CommandExecOptions +): Promise<{ stdout: string; stderr: string }> { + const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(command, args) + return new Promise((resolve, reject) => { + if (options.signal?.aborted) { + reject(createAbortError()) + return + } + let settled = false + let stdout = '' + let stderr = '' + let stdoutBytes = 0 + let stderrBytes = 0 + const spawnStartedAt = performance.now() + const child = spawn(spawnCmd, spawnArgs, { + cwd: options.cwd, + env: options.env, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true + }) + recordSubprocessSpawn(spawnCmd, spawnArgs, performance.now() - spawnStartedAt) + let timer: NodeJS.Timeout | null = null + const onAbort = (): void => { + void killSpawnedCommandTree(child) + finish(createAbortError()) + } + const cleanupListeners = (): void => { + if (timer) { + clearTimeout(timer) + timer = null + } + options.signal?.removeEventListener('abort', onAbort) + child.stdout?.off('data', onStdoutData) + child.stderr?.off('data', onStderrData) + child.off('error', onError) + child.off('close', onClose) + } + const finish = (error: Error | null): void => { + if (settled) { + return + } + settled = true + cleanupListeners() + if (error) { + reject(Object.assign(error, { stdout, stderr })) + return + } + resolve({ stdout, stderr }) + } + timer = options.timeout + ? setTimeout(() => { + void killSpawnedCommandTree(child) + finish(new Error(`${command} timed out.`)) + }, options.timeout) + : null + options.signal?.addEventListener('abort', onAbort, { once: true }) + function onStdoutData(chunk: Buffer): void { + stdoutBytes += chunk.byteLength + if (options.maxBuffer && stdoutBytes > options.maxBuffer) { + void killSpawnedCommandTree(child) + finish(new Error(`${command} stdout exceeded maxBuffer.`)) + return + } + stdout += chunk.toString(options.encoding ?? 'utf-8') + } + function onStderrData(chunk: Buffer): void { + stderrBytes += chunk.byteLength + if (options.maxBuffer && stderrBytes > options.maxBuffer) { + void killSpawnedCommandTree(child) + finish(new Error(`${command} stderr exceeded maxBuffer.`)) + return + } + stderr += chunk.toString(options.encoding ?? 'utf-8') + } + function onError(error: Error): void { + finish(error) + } + function onClose(code: number | null): void { + if (code === 0) { + finish(null) + return + } + finish(new Error(`${command} exited with ${code}.`)) + } + child.stdout?.on('data', onStdoutData) + child.stderr?.on('data', onStderrData) + child.on('error', onError) + child.on('close', onClose) + }) +} diff --git a/src/main/git/status-branch-compare-real-ref.test.ts b/src/main/git/status-branch-compare-real-ref.test.ts index ce8400d511e..dde7a62a291 100644 --- a/src/main/git/status-branch-compare-real-ref.test.ts +++ b/src/main/git/status-branch-compare-real-ref.test.ts @@ -3,7 +3,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import * as path from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { getBranchCompare } from './status' +import { getBranchCompare } from './branch-comparison' const tempRoots: string[] = [] diff --git a/src/main/git/status-branch-compare.test.ts b/src/main/git/status-branch-compare.test.ts index e5a676898bc..9a10a7e61a9 100644 --- a/src/main/git/status-branch-compare.test.ts +++ b/src/main/git/status-branch-compare.test.ts @@ -51,7 +51,8 @@ vi.mock('../../shared/node-bounded-file-reader', async (importOriginal) => }) ) -import { getBranchCompare, getCommitCompare } from './status' +import { getBranchCompare } from './branch-comparison' +import { getCommitCompare } from './commit-comparison' describe('getBranchCompare', () => { beforeEach(() => { diff --git a/src/main/git/status-branch-line-total-exec-contract.test.ts b/src/main/git/status-branch-line-total-exec-contract.test.ts index 7dff12b5491..a4f87e73ea4 100644 --- a/src/main/git/status-branch-line-total-exec-contract.test.ts +++ b/src/main/git/status-branch-line-total-exec-contract.test.ts @@ -10,6 +10,7 @@ import { import type * as BranchLineTotal from '../../shared/git-branch-line-total' import type * as GitRunner from './runner' +import type * as GitStream from './git-stream' // Why: real git, spied argv. Every exec is recorded so the performance contract // ("no ranged diff unless asked", "one exec for concurrent callers") can be @@ -51,8 +52,15 @@ vi.mock('./runner', async (importOriginal) => { ) => { gitExecCalls.push(args[0]) return actual.gitExecFileAsyncBuffer(...args) - }, - gitStreamStdout: async (...args: Parameters) => { + } + } +}) + +vi.mock('./git-stream', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + gitStreamStdout: async (...args: Parameters) => { gitExecCalls.push(args[0]) return actual.gitStreamStdout(...args) } @@ -61,10 +69,10 @@ vi.mock('./runner', async (importOriginal) => { import { clearEffectiveUpstreamStatusCacheForTests, - clearSubmodulePathsCacheForTests, - getStatus, invalidateGitReadCaches -} from './status' +} from './git-read-cache' +import { clearSubmodulePathsCacheForTests } from './submodule-paths' +import { getStatus } from './status' const BOGUS_MERGE_BASE = 'deadbeef'.repeat(5) // No fixture path here looks like test or generated code, so it is all source. diff --git a/src/main/git/status-branch-line-total-real-git.test.ts b/src/main/git/status-branch-line-total-real-git.test.ts index 1748efb2907..679593c6935 100644 --- a/src/main/git/status-branch-line-total-real-git.test.ts +++ b/src/main/git/status-branch-line-total-real-git.test.ts @@ -4,7 +4,8 @@ import { tmpdir } from 'node:os' import * as path from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { invalidateGitBranchLineTotalInFlight } from '../../shared/git-branch-line-total' -import { getStatus, invalidateGitReadCaches } from './status' +import { getStatus } from './status' +import { invalidateGitReadCaches } from './git-read-cache' const tempRoots: string[] = [] diff --git a/src/main/git/status-conflict-operations.test.ts b/src/main/git/status-conflict-operations.test.ts index e99c43532f4..63d38d5c563 100644 --- a/src/main/git/status-conflict-operations.test.ts +++ b/src/main/git/status-conflict-operations.test.ts @@ -51,7 +51,7 @@ vi.mock('../../shared/node-bounded-file-reader', async (importOriginal) => }) ) -import { abortMerge, abortRebase, detectConflictOperation } from './status' +import { abortMerge, abortRebase, detectConflictOperation } from './conflict-status' describe('abortMerge', () => { beforeEach(() => { diff --git a/src/main/git/status-conflict-overlap.bench.test.ts b/src/main/git/status-conflict-overlap.bench.test.ts index 5c92a23e10c..b8ec08ae85c 100644 --- a/src/main/git/status-conflict-overlap.bench.test.ts +++ b/src/main/git/status-conflict-overlap.bench.test.ts @@ -2,7 +2,7 @@ import { performance } from 'node:perf_hooks' import { join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as NodeFsPromises from 'node:fs/promises' -import type * as GitRunner from './runner' +import type * as GitStream from './git-stream' import type { GitExec } from '../../relay/git-handler-ops' import type { RelayGitStreamExec } from '../../relay/git-stdout-stream' @@ -16,8 +16,8 @@ vi.mock('node:fs/promises', async (importOriginal) => { return { ...actual, readFile: readFileMock } }) -vi.mock('./runner', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('./git-stream', async (importOriginal) => { + const actual = await importOriginal() return { ...actual, gitStreamStdout: gitStreamStdoutMock } }) diff --git a/src/main/git/status-cquoted-paths.test.ts b/src/main/git/status-cquoted-paths.test.ts index af77dcc2ff2..e33cb216d5b 100644 --- a/src/main/git/status-cquoted-paths.test.ts +++ b/src/main/git/status-cquoted-paths.test.ts @@ -3,7 +3,8 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import * as path from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { getStatus, stageFile } from './status' +import { getStatus } from './status' +import { stageFile } from './git-index-mutations' const tempRoots: string[] = [] diff --git a/src/main/git/status-diff.test.ts b/src/main/git/status-diff.test.ts index df59b1b9a3f..d29cc22553e 100644 --- a/src/main/git/status-diff.test.ts +++ b/src/main/git/status-diff.test.ts @@ -56,7 +56,11 @@ vi.mock('../../shared/node-bounded-file-reader', async (importOriginal) => }) ) -import { getBranchDiff, getCommitDiff, getDiff, getStagedCommitContext, stageFile } from './status' +import { getBranchDiff } from './branch-comparison' +import { getCommitDiff } from './commit-comparison' +import { getDiff } from './status-file-diff' +import { getStagedCommitContext } from './git-commit-operations' +import { stageFile } from './git-index-mutations' function deferredBuffer(content: string): { promise: Promise<{ stdout: Buffer }> diff --git a/src/main/git/status-discard-and-bulk-staging.test.ts b/src/main/git/status-discard-and-bulk-staging.test.ts index b1f39f9102b..e7812c8f433 100644 --- a/src/main/git/status-discard-and-bulk-staging.test.ts +++ b/src/main/git/status-discard-and-bulk-staging.test.ts @@ -52,13 +52,8 @@ vi.mock('../../shared/node-bounded-file-reader', async (importOriginal) => }) ) -import { - bulkDiscardChanges, - bulkStageFiles, - bulkUnstageFiles, - discardChanges, - isWithinWorktree -} from './status' +import { bulkDiscardChanges, discardChanges, isWithinWorktree } from './git-discard-changes' +import { bulkStageFiles, bulkUnstageFiles } from './git-index-mutations' describe('discardChanges', () => { beforeEach(() => { diff --git a/src/main/git/status-discard-symlink.test.ts b/src/main/git/status-discard-symlink.test.ts index 439ee3c7615..208834ebd6c 100644 --- a/src/main/git/status-discard-symlink.test.ts +++ b/src/main/git/status-discard-symlink.test.ts @@ -3,7 +3,7 @@ import { execFileSync } from 'node:child_process' import { mkdtemp, mkdir, rm, symlink, writeFile, access, readFile } from 'node:fs/promises' import * as path from 'node:path' import { tmpdir } from 'node:os' -import { bulkDiscardChanges, discardChanges } from './status' +import { bulkDiscardChanges, discardChanges } from './git-discard-changes' const tempRoots: string[] = [] diff --git a/src/main/git/status-file-diff.ts b/src/main/git/status-file-diff.ts new file mode 100644 index 00000000000..943a8e25b01 --- /dev/null +++ b/src/main/git/status-file-diff.ts @@ -0,0 +1,204 @@ +import * as path from 'node:path' +import type { GitDiffResult } from '../../shared/git-diff-compare-types' +import type { GitRuntimeOptions } from './git-runtime-options' +import { runGitDiffRead } from './git-diff-read-cache' +import { + buildDiffResult, + readGitBlobAtIndexPath, + readGitBlobAtOidPath, + readUnstagedLeftBlob, + readWorkingTreeFile +} from './git-blob-content' +import { + findContainingSubmodule, + listSubmodulePaths, + readGitlinkOidFromIndex, + readGitlinkOidFromTree, + readWorkingSubmoduleHead, + resolveSubmoduleWorktreePath +} from './submodule-paths' + +/** + * Synthesize a gitlink pointer diff: Git represents submodule commit changes as a + * one-line `Subproject commit ` swap, so the old/new oids feed the text differ. + */ +async function buildSubmodulePointerDiff( + worktreePath: string, + submodulePath: string, + staged: boolean, + compareAgainstHead: boolean, + options: GitRuntimeOptions, + // Why: default to the validated resolver so every caller is guarded against path escape. + submoduleWorktreePath = resolveSubmoduleWorktreePath(worktreePath, submodulePath) +): Promise { + let leftOid = '' + let rightOid = '' + if (staged) { + leftOid = await readGitlinkOidFromTree(worktreePath, 'HEAD', submodulePath, options) + rightOid = await readGitlinkOidFromIndex(worktreePath, submodulePath, options) + } else if (compareAgainstHead) { + leftOid = await readGitlinkOidFromTree(worktreePath, 'HEAD', submodulePath, options) + rightOid = await readWorkingSubmoduleHead(submoduleWorktreePath, options) + } else { + leftOid = + (await readGitlinkOidFromIndex(worktreePath, submodulePath, options)) || + (await readGitlinkOidFromTree(worktreePath, 'HEAD', submodulePath, options)) + rightOid = await readWorkingSubmoduleHead(submoduleWorktreePath, options) + } + return buildDiffResult( + leftOid ? `Subproject commit ${leftOid}\n` : '', + rightOid ? `Subproject commit ${rightOid}\n` : '', + false, + false, + submodulePath + ) +} + +/** + * Diff a file inside a submodule across two of its commits — used when the parent + * gitlink moved but the submodule worktree is clean (change is committed). + */ +async function buildSubmoduleInnerCommitRangeDiff( + submoduleWorktreePath: string, + innerPath: string, + fromOid: string, + toOid: string, + options: GitRuntimeOptions +): Promise { + let originalContent = '' + let modifiedContent = '' + let originalIsBinary = false + let modifiedIsBinary = false + try { + const left = await readGitBlobAtOidPath(submoduleWorktreePath, fromOid, innerPath, options) + originalContent = left.content + originalIsBinary = left.isBinary + const right = await readGitBlobAtOidPath(submoduleWorktreePath, toOid, innerPath, options) + modifiedContent = right.content + modifiedIsBinary = right.isBinary + } catch { + // Fallback to empty content; a missing blob (add/delete) reads as one side. + } + return buildDiffResult( + originalContent, + modifiedContent, + originalIsBinary, + modifiedIsBinary, + innerPath + ) +} + +/** + * Get original and modified content for diffing a file. + */ +export async function getDiff( + worktreePath: string, + filePath: string, + staged: boolean, + compareAgainstHead = false, + options: GitRuntimeOptions = {} +): Promise { + // Why: register the dedupe synchronously (before any await) so concurrent identical reads coalesce. + return runGitDiffRead(['diff', worktreePath, filePath, staged, compareAgainstHead], options, () => + loadDiff(worktreePath, filePath, staged, compareAgainstHead, options) + ) +} + +async function loadDiff( + worktreePath: string, + filePath: string, + staged: boolean, + compareAgainstHead: boolean, + options: GitRuntimeOptions +): Promise { + // Why: gitlink paths can't be read as blobs, so route submodule diffs explicitly (root → pointer, inner → recurse). + const submodulePaths = await listSubmodulePaths(worktreePath, options) + if (submodulePaths.length > 0) { + const matchedSubmodule = findContainingSubmodule(submodulePaths, filePath) + if (matchedSubmodule) { + // Why: validate the .gitmodules-derived path against the worktree boundary so a crafted one can't escape the repo. + const submoduleWorktreePath = resolveSubmoduleWorktreePath(worktreePath, matchedSubmodule) + const normalizedFilePath = filePath.replace(/\\/g, '/').replace(/\/+$/, '') + if (normalizedFilePath === matchedSubmodule) { + return buildSubmodulePointerDiff( + worktreePath, + matchedSubmodule, + staged, + compareAgainstHead, + options, + submoduleWorktreePath + ) + } + const innerPath = normalizedFilePath.slice(matchedSubmodule.length + 1) + const fromOid = staged + ? await readGitlinkOidFromTree(worktreePath, 'HEAD', matchedSubmodule, options) + : (await readGitlinkOidFromIndex(worktreePath, matchedSubmodule, options)) || + (await readGitlinkOidFromTree(worktreePath, 'HEAD', matchedSubmodule, options)) + const toOid = staged + ? await readGitlinkOidFromIndex(worktreePath, matchedSubmodule, options) + : await readWorkingSubmoduleHead(submoduleWorktreePath, options) + // Why: a moved gitlink with a clean submodule worktree means the change is committed — diff the two commits. + if (fromOid && toOid && fromOid !== toOid) { + return buildSubmoduleInnerCommitRangeDiff( + submoduleWorktreePath, + innerPath, + fromOid, + toOid, + options + ) + } + return getDiff(submoduleWorktreePath, innerPath, staged, compareAgainstHead, options) + } + } + + let originalContent = '' + let modifiedContent = '' + let originalIsBinary = false + let modifiedIsBinary = false + let modifiedDeleted = false + + try { + if (staged) { + // Why concurrent: HEAD and the index are independent `git show` spawns. + // Only this branch qualifies — the unstaged left read chains index→HEAD. + const [leftBlob, rightBlob] = await Promise.all([ + readGitBlobAtOidPath(worktreePath, 'HEAD', filePath, options), + readGitBlobAtIndexPath(worktreePath, filePath, options) + ]) + originalContent = leftBlob.content + originalIsBinary = leftBlob.isBinary + modifiedContent = rightBlob.content + modifiedIsBinary = rightBlob.isBinary + modifiedDeleted = !rightBlob.exists + } else { + // The left chain (index→HEAD) is sequential within itself, but the working + // tree read is a plain fs read that does not depend on it. + const [leftBlob, workingTreeBlob] = await Promise.all([ + compareAgainstHead + ? readGitBlobAtOidPath(worktreePath, 'HEAD', filePath, options) + : readUnstagedLeftBlob(worktreePath, filePath, options), + readWorkingTreeFile(path.join(worktreePath, filePath)) + ]) + originalContent = leftBlob.content + originalIsBinary = leftBlob.isBinary + modifiedContent = workingTreeBlob.content + modifiedIsBinary = workingTreeBlob.isBinary + modifiedDeleted = !workingTreeBlob.exists + } + } catch { + // Fallback + } + + const result = buildDiffResult( + originalContent, + modifiedContent, + originalIsBinary, + modifiedIsBinary, + filePath + ) + // Why: mark a proven deletion so previewers don't mistake a read failure's empty side for one. + if (result.kind === 'binary' && modifiedDeleted) { + return { ...result, modifiedDeleted: true } + } + return result +} diff --git a/src/main/git/status-line-stats.ts b/src/main/git/status-line-stats.ts new file mode 100644 index 00000000000..71c2dfa3c9b --- /dev/null +++ b/src/main/git/status-line-stats.ts @@ -0,0 +1,128 @@ +import type { GitStatusEntry } from '../../shared/git-status-types' +import { + applyLineStats, + collectUntrackedAdditions, + parseNumstat, + type GitLineStats +} from '../../shared/git-uncommitted-line-stats' +import { + computeGitBranchLineTotal, + readGitBranchLineTotalMergeBaseParam, + GIT_BRANCH_LINE_TOTAL_TIMEOUT_MS, + type GitBranchLineTotal +} from '../../shared/git-branch-line-total' +import { gitExecFileAsync } from './runner' +import { gitOptionalLocksDisabledEnv } from './git-environment-policy' +import type { GitRuntimeOptions } from './git-runtime-options' +import { gitStatusReadOptionsForWorktree } from './git-runtime-options' +import type { GetStatusOptions } from './status-query-options' + +/** Undefined — and therefore zero extra work — unless the caller asked for a total we can know exact. */ +export function createBranchLineTotalInput( + worktreePath: string, + entries: GitStatusEntry[], + options: GetStatusOptions, + statusSucceeded: boolean +): { mergeBase: string; compute: () => Promise } | undefined { + const mergeBase = readGitBranchLineTotalMergeBaseParam(options.branchLineTotalMergeBase) + // Why: a failed status scan leaves the untracked list untrustworthy, so the + // total would silently under-count rather than be absent. + if (mergeBase === undefined || !statusSucceeded) { + return undefined + } + return { + mergeBase, + compute: () => + computeGitBranchLineTotal({ + worktreePath, + // Why: the same path can be a different filesystem per WSL distro. + hostKey: options.wslDistro ?? 'native', + mergeBase, + untrackedPaths: entries + .filter((entry) => entry.area === 'untracked') + .map((entry) => entry.path), + runDiffNumstat: (args, signal) => + gitExecFileAsync(args, { + ...gitStatusReadOptionsForWorktree(worktreePath, options), + // Why: after the spread, so the shared lease signal wins over this caller's own. + signal, + env: gitOptionalLocksDisabledEnv(), + timeout: GIT_BRANCH_LINE_TOTAL_TIMEOUT_MS + }).then((result) => result.stdout), + ...(options.signal ? { signal: options.signal } : {}) + }) + } +} + +export function getStatusLineStatsCacheKey( + worktreePath: string, + options: GitRuntimeOptions = {} +): string { + // Why: identical paths can map to different WSL-distro filesystems, so key stats by Git's execution host. + return `${options.wslDistro ?? 'native'}\0${worktreePath}` +} + +async function runNumstat( + worktreePath: string, + cached: boolean, + options: GitRuntimeOptions = {} +): Promise | null> { + try { + const { stdout } = await gitExecFileAsync( + [ + '-c', + 'core.quotePath=false', + 'diff', + '-z', + ...(cached ? ['--cached'] : []), + '--numstat', + '-M' + ], + { + ...gitStatusReadOptionsForWorktree(worktreePath, options), + env: gitOptionalLocksDisabledEnv() + } + ) + return parseNumstat(stdout) + } catch (error) { + // Why: an aborted pass must reject; only a genuine numstat failure degrades to uncounted rows. + if (options.signal?.aborted) { + throw error + } + // Why: a numstat failure leaves rows uncounted; null (not empty map) flags the pass incomplete and uncacheable. + return null + } +} + +/** Returns false when a numstat pass failed, so callers skip caching it. */ +export async function attachLineStats( + worktreePath: string, + entries: GitStatusEntry[], + options: GitRuntimeOptions = {} +): Promise { + if (entries.length === 0) { + return true + } + const hasStaged = entries.some((entry) => entry.area === 'staged') + const hasUnstaged = entries.some((entry) => entry.area === 'unstaged') + const untrackedPaths = entries + .filter((entry) => entry.area === 'untracked') + .map((entry) => entry.path) + const emptyStats = new Map() + const [stagedStats, unstagedStats, untrackedStats] = await Promise.all([ + hasStaged ? runNumstat(worktreePath, true, options) : Promise.resolve(emptyStats), + hasUnstaged ? runNumstat(worktreePath, false, options) : Promise.resolve(emptyStats), + collectUntrackedAdditions(worktreePath, untrackedPaths, options.signal) + ]) + for (const entry of entries) { + applyLineStats( + entry, + entry.area === 'staged' + ? (stagedStats ?? emptyStats).get(entry.path) + : entry.area === 'unstaged' + ? (unstagedStats ?? emptyStats).get(entry.path) + : untrackedStats.get(entry.path) + ) + } + return stagedStats !== null && unstagedStats !== null +} diff --git a/src/main/git/status-pathspec-literals.test.ts b/src/main/git/status-pathspec-literals.test.ts index ba777bfcfa8..5299647cef0 100644 --- a/src/main/git/status-pathspec-literals.test.ts +++ b/src/main/git/status-pathspec-literals.test.ts @@ -3,7 +3,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import * as path from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { bulkStageFiles, bulkUnstageFiles, stageFile, unstageFile } from './status' +import { bulkStageFiles, bulkUnstageFiles, stageFile, unstageFile } from './git-index-mutations' const tempRoots: string[] = [] const globNamedFile = '[k]eep.log' diff --git a/src/main/git/status-query-options.ts b/src/main/git/status-query-options.ts new file mode 100644 index 00000000000..8acee572fec --- /dev/null +++ b/src/main/git/status-query-options.ts @@ -0,0 +1,13 @@ +import type { GitRuntimeOptions } from './git-runtime-options' + +export type GetStatusOptions = GitRuntimeOptions & { + includeIgnored?: boolean + reuseLineStats?: boolean + /** Merge-base OID the caller wants the branch line total measured against. */ + branchLineTotalMergeBase?: string + /** Max changed-file entries; 0 disables the cap. */ + limit?: number + bypassEffectiveUpstreamNegativeCache?: boolean + /** Orca-managed shared links that may need filtering from untracked rows. */ + sharedLinkPaths?: readonly string[] +} diff --git a/src/main/git/status-read-coalescing.test.ts b/src/main/git/status-read-coalescing.test.ts index 6eacba1fb69..77b0b4196fa 100644 --- a/src/main/git/status-read-coalescing.test.ts +++ b/src/main/git/status-read-coalescing.test.ts @@ -5,7 +5,8 @@ import path from 'node:path' import { createBoundedFileReaderModuleMock, createFsPromisesModuleMock, - createGitRunnerModuleMock + createGitRunnerModuleMock, + createGitStreamModuleMock } from './status-test-harness' const { @@ -37,6 +38,13 @@ vi.mock('./runner', () => gitStreamOptionsMock }) ) +vi.mock('./git-stream', () => + createGitStreamModuleMock({ + gitExecFileAsyncMock, + gitExecFileAsyncBufferMock, + gitStreamOptionsMock + }) +) vi.mock('fs/promises', () => createFsPromisesModuleMock({ lstatMock, realpathMock, readFileMock, statMock, rmMock }) @@ -53,7 +61,9 @@ vi.mock('../../shared/node-bounded-file-reader', async (importOriginal) => }) ) -import { clearEffectiveUpstreamStatusCacheForTests, getStatus, stageFile } from './status' +import { clearEffectiveUpstreamStatusCacheForTests } from './git-read-cache' +import { getStatus } from './status' +import { stageFile } from './git-index-mutations' describe('getStatus', () => { beforeEach(() => { diff --git a/src/main/git/status-submodule-path-cache.test.ts b/src/main/git/status-submodule-path-cache.test.ts index 60adccf142c..b203b6637e7 100644 --- a/src/main/git/status-submodule-path-cache.test.ts +++ b/src/main/git/status-submodule-path-cache.test.ts @@ -20,12 +20,11 @@ vi.mock('./runner', () => ({ import { MAX_SUBMODULE_PATHS_CACHE_ENTRIES, - abortMerge, - abortRebase, clearSubmodulePathsCacheForTests, getSubmodulePathsCacheCountForTests, listSubmodulePaths -} from './status' +} from './submodule-paths' +import { abortMerge, abortRebase } from './conflict-status' import { checkoutBranch } from './checkout' import { gitPull, gitPullRebaseFromBase } from './remote' import { addWorktree, removeWorktree } from './worktree' diff --git a/src/main/git/status-submodule.test.ts b/src/main/git/status-submodule.test.ts index 138d2874b19..e4771a7f7ce 100644 --- a/src/main/git/status-submodule.test.ts +++ b/src/main/git/status-submodule.test.ts @@ -4,7 +4,8 @@ import path from 'node:path' import { createBoundedFileReaderModuleMock, createFsPromisesModuleMock, - createGitRunnerModuleMock + createGitRunnerModuleMock, + createGitStreamModuleMock } from './status-test-harness' const { @@ -36,6 +37,13 @@ vi.mock('./runner', () => gitStreamOptionsMock }) ) +vi.mock('./git-stream', () => + createGitStreamModuleMock({ + gitExecFileAsyncMock, + gitExecFileAsyncBufferMock, + gitStreamOptionsMock + }) +) vi.mock('fs/promises', () => createFsPromisesModuleMock({ lstatMock, realpathMock, readFileMock, statMock, rmMock }) @@ -52,14 +60,14 @@ vi.mock('../../shared/node-bounded-file-reader', async (importOriginal) => }) ) +import { clearEffectiveUpstreamStatusCacheForTests } from './git-read-cache' import { - clearEffectiveUpstreamStatusCacheForTests, clearSubmodulePathsCacheForTests, - getDiff, - getSubmoduleStatus, listSubmodulePaths, resolveSubmoduleWorktreePath -} from './status' +} from './submodule-paths' +import { getDiff } from './status-file-diff' +import { getSubmoduleStatus } from './submodule-status' describe('submodule diff routing', () => { const OLD_OID = 'a'.repeat(40) diff --git a/src/main/git/status-test-harness.ts b/src/main/git/status-test-harness.ts index eda7e2f5014..88b494f9593 100644 --- a/src/main/git/status-test-harness.ts +++ b/src/main/git/status-test-harness.ts @@ -42,6 +42,21 @@ export function createGitRunnerModuleMock(mocks: GitRunnerMocks): Record { + return { + // Why: status tests seed the status call through gitExecFileAsyncMock; preserve + // that queue while mocking the concrete stream module used by production. + gitStreamStdout: async ( + args: string[], + options: { signal?: AbortSignal; onStdout: (chunk: string) => boolean | void } + ) => { + mocks.gitStreamOptionsMock(options) + const { stdout } = await mocks.gitExecFileAsyncMock(args) + const stoppedEarly = options.onStdout(stdout ?? '') === true + return { stoppedEarly } + } + } +} export function createFsPromisesModuleMock(mocks: FsPromisesMocks): Record { return { diff --git a/src/main/git/status-upstream-negative-cache.test.ts b/src/main/git/status-upstream-negative-cache.test.ts index 263842810a2..6acf8cd391e 100644 --- a/src/main/git/status-upstream-negative-cache.test.ts +++ b/src/main/git/status-upstream-negative-cache.test.ts @@ -21,6 +21,16 @@ vi.mock('./runner', () => ({ GIT_OPTIONAL_LOCKS: '0' }) })) +vi.mock('./git-stream', () => ({ + gitStreamStdout: async ( + args: string[], + options: { onStdout: (chunk: string) => boolean | void } + ) => { + const { stdout } = await gitExecFileAsyncMock(args) + const stoppedEarly = options.onStdout(stdout ?? '') === true + return { stoppedEarly } + } +})) vi.mock('fs/promises', () => ({ readFile: readFileMock @@ -40,11 +50,11 @@ function emptyGitConfigSnapshot(): { stdout: string } { import { clearEffectiveUpstreamNegativeStatusCache, - clearEffectiveUpstreamStatusCacheForTests, getEffectiveUpstreamStatusCacheCountForTests, - getEffectiveUpstreamStatusGenerationCountForTests, - getStatus -} from './status' + getEffectiveUpstreamStatusGenerationCountForTests +} from './effective-upstream-status-cache' +import { clearEffectiveUpstreamStatusCacheForTests } from './git-read-cache' +import { getStatus } from './status' describe('local upstream negative cache', () => { beforeEach(() => { diff --git a/src/main/git/status-upstream-probe-churn.test.ts b/src/main/git/status-upstream-probe-churn.test.ts index 49f6913e0af..8591924d2cd 100644 --- a/src/main/git/status-upstream-probe-churn.test.ts +++ b/src/main/git/status-upstream-probe-churn.test.ts @@ -26,6 +26,16 @@ vi.mock('./runner', () => ({ GIT_OPTIONAL_LOCKS: '0' }) })) +vi.mock('./git-stream', () => ({ + gitStreamStdout: async ( + args: string[], + options: { onStdout: (chunk: string) => boolean | void } + ) => { + const { stdout } = await gitExecFileAsyncMock(args) + const stoppedEarly = options.onStdout(stdout ?? '') === true + return { stoppedEarly } + } +})) vi.mock('fs/promises', () => ({ readFile: readFileMock @@ -35,7 +45,8 @@ vi.mock('fs', () => ({ existsSync: existsSyncMock })) -import { clearEffectiveUpstreamStatusCacheForTests, getStatus } from './status' +import { clearEffectiveUpstreamStatusCacheForTests } from './git-read-cache' +import { getStatus } from './status' function getGitArgs(call: unknown[]): string[] { return call[0] as string[] diff --git a/src/main/git/status-wsl-pathspecs.test.ts b/src/main/git/status-wsl-pathspecs.test.ts index e802fb72b5b..05b8bd67898 100644 --- a/src/main/git/status-wsl-pathspecs.test.ts +++ b/src/main/git/status-wsl-pathspecs.test.ts @@ -14,14 +14,8 @@ vi.mock('./runner', () => ({ gitStreamStdout: vi.fn() })) -import { - bulkDiscardChanges, - bulkStageFiles, - bulkUnstageFiles, - discardChanges, - stageFile, - unstageFile -} from './status' +import { bulkDiscardChanges, discardChanges } from './git-discard-changes' +import { bulkStageFiles, bulkUnstageFiles, stageFile, unstageFile } from './git-index-mutations' const worktreePath = path.resolve('repo') const windowsRelativePath = 'tests\\breakgit' diff --git a/src/main/git/status.test.ts b/src/main/git/status.test.ts index e658e773743..23d3f6d8643 100644 --- a/src/main/git/status.test.ts +++ b/src/main/git/status.test.ts @@ -3,7 +3,8 @@ import type * as BoundedFileReader from '../../shared/node-bounded-file-reader' import { createBoundedFileReaderModuleMock, createFsPromisesModuleMock, - createGitRunnerModuleMock + createGitRunnerModuleMock, + createGitStreamModuleMock } from './status-test-harness' const { @@ -35,6 +36,13 @@ vi.mock('./runner', () => gitStreamOptionsMock }) ) +vi.mock('./git-stream', () => + createGitStreamModuleMock({ + gitExecFileAsyncMock, + gitExecFileAsyncBufferMock, + gitStreamOptionsMock + }) +) vi.mock('fs/promises', () => createFsPromisesModuleMock({ lstatMock, realpathMock, readFileMock, statMock, rmMock }) @@ -51,7 +59,9 @@ vi.mock('../../shared/node-bounded-file-reader', async (importOriginal) => }) ) -import { clearEffectiveUpstreamStatusCacheForTests, getStatus, stageFile } from './status' +import { clearEffectiveUpstreamStatusCacheForTests } from './git-read-cache' +import { getStatus } from './status' +import { stageFile } from './git-index-mutations' describe('getStatus', () => { beforeEach(() => { diff --git a/src/main/git/status.ts b/src/main/git/status.ts index 7abfd56c888..b1ac4f4acc9 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -1,241 +1,45 @@ -/* eslint-disable max-lines */ -import { existsSync } from 'node:fs' -import { readFile, stat } from 'node:fs/promises' -import * as path from 'node:path' import type { - GitBranchChangeEntry, - GitBranchCompareResult, - GitBranchCompareSummary, - GitCommitCompareResult, - GitDiffResult -} from '../../shared/git-diff-compare-types' -import type { - GitBranchChangeStatus, - GitConflictKind, - GitConflictOperation, - GitFileStatus, GitStatusEntry, GitStatusResult, GitUpstreamStatus } from '../../shared/git-status-types' -import type { CommitMessageDraftContext } from '../../shared/commit-message-generation' -import { - getEffectiveGitUpstreamStatus, - getGitUpstreamStatusForUpstreamName, - splitRemoteBranchName -} from '../../shared/git-effective-upstream' -import { createGitConfigSnapshotRunner } from '../../shared/git-config-snapshot-runner' -import { isBinaryBuffer } from '../../shared/binary-buffer' -import { - applyLineStats, - collectUntrackedAdditions, - parseNumstat, - type GitLineStats -} from '../../shared/git-uncommitted-line-stats' -import { decodeGitCQuotedPath } from '../../shared/git-cquoted-path' -import { - gitExecFileAsync, - gitExecFileAsyncBuffer, - gitOptionalLocksDisabledEnv, - gitStreamStdout -} from './runner' import { StatusPorcelainParser } from '../../shared/git-status-porcelain-parser' -import { findExistingWorktreeSymlinkPaths } from './worktree-symlink-detection' -import { capGitStatusEntries, resolveGitStatusLimit } from '../../shared/git-status-limit' -import { describeMaxBufferOverflowError, isMaxBufferOverflowError } from './max-buffer-overflow' -import { - removeSafeUntrackedDiscardTarget, - removeSafeUntrackedDiscardTargets -} from '../../shared/git-discard-path-safety' -import { readBranchCompareHead } from '../../shared/git-branch-compare-head' -import { resolveWorktreeAddBaseRef } from '../../shared/worktree/base-ref' -import { resolveWorktreeBaseCommitOid } from './worktree-base-ref-probe' -import { getLargeDiffRenderLimit } from '../../shared/large-diff-render-limit' -import { InFlightPromiseDedupe, stableInFlightKey } from '../../shared/in-flight-promise-dedupe' -import type { GitRuntimeOptions } from './git-runtime-options' -import { gitOptionsForWorktree, gitStatusReadOptionsForWorktree } from './git-runtime-options' -import { GitStatusReadLeaseOwner } from './git-status-read-lease-owner' -import { parseGitRevListFirstParentOid } from '../../shared/git-rev-list-output' -import { invalidateGitUpstreamStatusReads } from './upstream' -import { - computeGitBranchLineTotal, - invalidateGitBranchLineTotalInFlight, - readGitBranchLineTotalMergeBaseParam, - GIT_BRANCH_LINE_TOTAL_TIMEOUT_MS, - type GitBranchLineTotal -} from '../../shared/git-branch-line-total' +import { resolveGitStatusLimit } from '../../shared/git-status-limit' +import { stableInFlightKey } from '../../shared/in-flight-promise-dedupe' import { beginGitStatusLineStatsCacheWrite, - clearGitStatusLineStatsCache, clearGitStatusLineStatsCacheKey, reuseOrRecomputeGitStatusLineStats } from '../../shared/git-status-line-stats-cache' +import type { GitBranchLineTotal } from '../../shared/git-branch-line-total' +import { findExistingWorktreeSymlinkPaths } from './worktree-symlink-detection' +import { gitOptionalLocksDisabledEnv } from './git-environment-policy' +import { gitStreamStdout } from './git-stream' +import type { GetStatusOptions } from './status-query-options' +import { leaseGitStatusRead } from './git-read-cache' +import { clearGitDiffReadCache } from './git-diff-read-cache' +import { detectConflictOperation, parseUnmergedEntry } from './conflict-status' +import { + getEffectiveUpstreamStatusCacheKey, + getShortBranchName, + readOrProbeEffectiveUpstreamStatus, + shouldProbeEffectiveUpstreamStatus +} from './effective-upstream-status-cache' +import { + attachLineStats, + createBranchLineTotalInput, + getStatusLineStatsCacheKey +} from './status-line-stats' -const MAX_GIT_SHOW_BYTES = 10 * 1024 * 1024 -const MAX_STAGED_COMMIT_CONTEXT_BYTES = MAX_GIT_SHOW_BYTES -const BULK_CHUNK_SIZE = 100 -const EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_TTL_MS = 5 * 60_000 -const MAX_EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_ENTRIES = 512 - -type EffectiveUpstreamStatusCacheEntry = { - expiresAt: number - status: GitUpstreamStatus -} - -const SUBMODULE_PATHS_CACHE_TTL_MS = 5_000 -export const MAX_SUBMODULE_PATHS_CACHE_ENTRIES = 512 -type SubmodulePathsCacheEntry = { paths: string[]; expiresAt: number } -const submodulePathsCache = new Map() -let submodulePathsCacheGeneration = 0 - -// Why: cache the upstream name to skip its 4-5-spawn resolution chain each poll; revalidate via one rev-list (issue #7576). -const RESOLVED_UPSTREAM_NAME_CACHE_TTL_MS = 60_000 - -type ResolvedUpstreamNameCacheEntry = { - upstreamName: string - expiresAt: number -} - -const resolvedUpstreamNameCache = new Map() - -const effectiveUpstreamStatusCache = new Map() -const effectiveUpstreamStatusInFlight = new Map>() -const retiredEffectiveUpstreamStatusInFlight = new Map>() -const gitDiffReadDedupe = new InFlightPromiseDedupe() -const effectiveUpstreamStatusWriteGeneration = new Map() -const statusReadLeaseOwner = new GitStatusReadLeaseOwner() - -// Why: clear every in-flight git read cache; clearing only some would let a post-mutation -// getStatus() join a pre-mutation read and publish it as current. -export function invalidateGitReadCaches(): void { - gitDiffReadDedupe.clear() - statusReadLeaseOwner.invalidate() - invalidateGitBranchLineTotalInFlight() - invalidateGitUpstreamStatusReads() - clearGitStatusLineStatsCache() - clearSubmodulePathsCache() - resolvedUpstreamNameCache.clear() -} - -export async function runWithGitReadCacheInvalidation(run: () => Promise): Promise { - invalidateGitReadCaches() - try { - return await run() - } finally { - // Why: a read that started mid-mutation can be stale too, so invalidate again after. - invalidateGitReadCaches() - } -} - -export function clearSubmodulePathsCacheForTests(): void { - clearSubmodulePathsCache() -} - -function clearSubmodulePathsCache(): void { - submodulePathsCache.clear() - // Why: bump the generation so a pre-mutation read can't repopulate the invalidated cache. - submodulePathsCacheGeneration += 1 -} - -export function getSubmodulePathsCacheCountForTests(): number { - return submodulePathsCache.size -} - -function gitRuntimeOptionsKey(options: GitRuntimeOptions): readonly unknown[] { - return [options.wslDistro ?? null] -} - -function getSubmodulePathsCacheKey(worktreePath: string, options: GitRuntimeOptions): string { - // Why: the same path can map to different WSL-distro filesystems, so key the cache by runtime routing. - return [worktreePath, ...gitRuntimeOptionsKey(options)].join('\0') -} - -function pruneExpiredSubmodulePathsCache(now: number): void { - for (const [cacheKey, entry] of submodulePathsCache) { - if (entry.expiresAt <= now) { - submodulePathsCache.delete(cacheKey) - } - } -} - -function trimSubmodulePathsCache(): void { - while (submodulePathsCache.size > MAX_SUBMODULE_PATHS_CACHE_ENTRIES) { - const oldestKey = submodulePathsCache.keys().next().value - if (oldestKey === undefined) { - break - } - submodulePathsCache.delete(oldestKey) - } -} - -function getCachedSubmodulePaths(cacheKey: string, now: number): string[] | null { - const cached = submodulePathsCache.get(cacheKey) - if (!cached) { - return null - } - if (cached.expiresAt <= now) { - submodulePathsCache.delete(cacheKey) - return null - } - submodulePathsCache.delete(cacheKey) - submodulePathsCache.set(cacheKey, cached) - return cached.paths -} - -function rememberSubmodulePaths(cacheKey: string, paths: string[], now: number): void { - submodulePathsCache.delete(cacheKey) - submodulePathsCache.set(cacheKey, { paths, expiresAt: now + SUBMODULE_PATHS_CACHE_TTL_MS }) - trimSubmodulePathsCache() -} - -// Why: tests reuse this hook, so every memoization layer resets together despite the upstream-only name. -export function clearEffectiveUpstreamStatusCacheForTests(): void { - effectiveUpstreamStatusCache.clear() - effectiveUpstreamStatusInFlight.clear() - retiredEffectiveUpstreamStatusInFlight.clear() - effectiveUpstreamStatusWriteGeneration.clear() - invalidateGitReadCaches() -} - -export function getEffectiveUpstreamStatusCacheCountForTests(): number { - return effectiveUpstreamStatusCache.size -} - -export function getEffectiveUpstreamStatusGenerationCountForTests(): number { - return effectiveUpstreamStatusWriteGeneration.size -} - -export type GetStatusOptions = GitRuntimeOptions & { - includeIgnored?: boolean - reuseLineStats?: boolean - /** Merge-base OID the caller wants the branch line total measured against; - * omitted means the chip is hidden, so no ranged diff runs at all. */ - branchLineTotalMergeBase?: string - /** - * Max changed-file entries before git is stopped and the result is marked - * `didHitLimit`. Defaults to DEFAULT_GIT_STATUS_LIMIT; 0 disables the cap. - */ - limit?: number - bypassEffectiveUpstreamNegativeCache?: boolean - /** Paths Orca may have symlinked into this worktree (per-user shared paths - * plus `orca.yaml` shared directories). Untracked entries that are one of - * these *and* really symlinks are dropped: Git cannot ignore them when the - * repo's rule is directory-only (`node_modules/`), but they are Orca's own - * artifacts, not user work. */ - sharedLinkPaths?: readonly string[] -} - -/** - * Parse `git status --porcelain=v2` output into structured entries. - */ +/** Parse `git status --porcelain=v2` output into structured entries. */ export async function getStatus( worktreePath: string, options: GetStatusOptions = {} ): Promise { - gitDiffReadDedupe.clear() + clearGitDiffReadCache() // Why: dedupe only concurrent identical reads; after settle, callers must run a fresh read. const cacheKey = getStatusReadKey(worktreePath, options) - return statusReadLeaseOwner.lease(cacheKey, options.signal, (sharedSignal) => + return leaseGitStatusRead(cacheKey, options.signal, (sharedSignal) => runGetStatus(worktreePath, { ...options, signal: sharedSignal }) ) } @@ -248,8 +52,6 @@ function getStatusReadKey(worktreePath: string, options: GetStatusOptions): stri options.wslDistro ?? '', options.includeIgnored === true, options.reuseLineStats === true, - // Why: the result carries a total only for callers who asked, and only for - // this fork point, so a shared lease must never serve one to the other. options.branchLineTotalMergeBase ?? '', options.bypassEffectiveUpstreamNegativeCache === true, limit, @@ -258,23 +60,12 @@ function getStatusReadKey(worktreePath: string, options: GetStatusOptions): stri ]) } -/** Remove untracked entries that are shared symlinks Orca created. - * - * Why this can't be left to Git: a directory-only ignore rule (`node_modules/`) - * matches the primary checkout's real directory but never the worktree's - * symlink, so Git reports it untracked forever — a phantom row in the diff and - * a permanently "dirty" worktree. - * - * Tight on both axes: an entry must be configured as shared *and* actually be a - * symlink. A regular file the user created at a configured name still shows up, - * and so does a symlink at a path nobody declared shared. Mutates `entries`. */ async function dropSharedSymlinkUntrackedEntries( worktreePath: string, entries: GitStatusEntry[], sharedLinkPaths: readonly string[] ): Promise { - // Why: a clean tree has no untracked entries, so this costs nothing on the - // common status-poll path — no syscall, no config read, no subprocess. + // Why: a clean tree has no untracked entries, so this costs nothing on the common poll path. if (sharedLinkPaths.length === 0 || !entries.some((entry) => entry.area === 'untracked')) { return } @@ -298,12 +89,10 @@ async function runGetStatus( const lineStatsWriteToken = beginGitStatusLineStatsCacheWrite(lineStatsCacheKey) let effectiveUpstreamStatus: GitUpstreamStatus | undefined let statusSucceeded = false - // Why: a bad limit (negative/fractional/NaN) breaks early-stop; require a valid non-negative int (0 disables the cap). const limit = resolveGitStatusLimit(options.limit) - // Why: detectConflictOperation and git status are independent, so run them concurrently to save I/O latency. + // Why: conflict detection and status are independent, so run concurrently. const conflictPromise = detectConflictOperation(worktreePath) - // Why: core.quotePath=false keeps non-ASCII paths as raw UTF-8, not octal escapes, so entry.path is readable and lookups match. const statusArgs = [ '-c', 'core.quotePath=false', @@ -316,17 +105,15 @@ async function runGetStatus( statusArgs.push('--ignored=matching') } - // Why: stream + parse and stop at `limit` so a huge un-ignored folder can't buffer enough to crash the process. const parser = new StatusPorcelainParser() let didHitLimit = false - // Why: attach rejection ownership before awaiting marker I/O, so a fast Git failure cannot become unhandled. + // Why: attach rejection ownership before awaiting marker I/O so a fast Git failure is handled. const statusSettlementPromise = Promise.allSettled([ (async () => { const result = await gitStreamStdout(statusArgs, { cwd: worktreePath, wslDistro: options.wslDistro, preferWslDirectGit: true, - // Why: status polling is read-like; disable optional locks to avoid racing terminal Git on index.lock. env: gitOptionalLocksDisabledEnv(), signal: options.signal, onStdout: (chunk) => parser.update(chunk, limit) @@ -347,18 +134,15 @@ async function runGetStatus( didHitLimit = statusResult.value.stoppedEarly statusSucceeded = true } catch (error) { - // Why: an aborted scan must reject, not resolve as an empty result. if (options.signal?.aborted) { throw error } - // Not a git repo or git not available + // Not a Git repository or Git is unavailable. } const entries: GitStatusEntry[] = [] const { head, branch, upstreamName, upstreamAheadBehind } = parser.branch - - // Why: resolve deferred conflicts in Git's output order so the cap cannot hide - // an early conflict behind ordinary rows that appeared later in the stream. + // Why: resolve deferred conflicts in Git output order so the cap preserves ordering. for (const record of parser.statusRecords) { if (didHitLimit && entries.length >= limit) { break @@ -385,7 +169,7 @@ async function runGetStatus( options ) try { - // Why: the shared probe/caches serve concurrent reads, so run it unbound from this signal — one abort mustn't reject it for others. + // Why: shared probes are unbound from one request signal; one abort must not reject others. const { signal: _requestSignal, ...sharedProbeOptions } = options effectiveUpstreamStatus = await readOrProbeEffectiveUpstreamStatus( cacheKey, @@ -395,12 +179,10 @@ async function runGetStatus( options.bypassEffectiveUpstreamNegativeCache === true ) } catch { - // Why: don't fail status polling on a transient upstream-probe error; the explicit upstream path surfaces those. + // Status polling degrades on transient upstream-probe errors. } } } - - // Why: line counts run only for areas with entries (clean tree = 0 calls); skip past the limit to avoid numstat over a huge set. let branchLineTotal: GitBranchLineTotal | undefined if (!didHitLimit) { const branchLineTotalInput = createBranchLineTotalInput( @@ -424,7 +206,6 @@ async function runGetStatus( clearGitStatusLineStatsCacheKey(lineStatsCacheKey, lineStatsWriteToken) } - // Why: an abort after the stream (unmerged/upstream/line-stats work) must still reject, not resolve. if (options.signal?.aborted) { const error = new Error('The operation was aborted.') error.name = 'AbortError' @@ -455,1908 +236,3 @@ async function runGetStatus( : {}) } } - -/** Undefined — and therefore zero extra work — unless the caller asked for a total we can know exact. */ -function createBranchLineTotalInput( - worktreePath: string, - entries: GitStatusEntry[], - options: GetStatusOptions, - statusSucceeded: boolean -): { mergeBase: string; compute: () => Promise } | undefined { - const mergeBase = readGitBranchLineTotalMergeBaseParam(options.branchLineTotalMergeBase) - // Why: a failed status scan leaves the untracked list untrustworthy, so the - // total would silently under-count rather than be absent. - if (mergeBase === undefined || !statusSucceeded) { - return undefined - } - return { - mergeBase, - compute: () => - computeGitBranchLineTotal({ - worktreePath, - // Why: the same path can be a different filesystem per WSL distro. - hostKey: options.wslDistro ?? 'native', - mergeBase, - untrackedPaths: entries - .filter((entry) => entry.area === 'untracked') - .map((entry) => entry.path), - runDiffNumstat: (args, signal) => - gitExecFileAsync(args, { - ...gitStatusReadOptionsForWorktree(worktreePath, options), - // Why: after the spread, so the shared lease signal wins over this caller's own. - signal, - env: gitOptionalLocksDisabledEnv(), - timeout: GIT_BRANCH_LINE_TOTAL_TIMEOUT_MS - }).then((result) => result.stdout), - ...(options.signal ? { signal: options.signal } : {}) - }) - } -} - -function getStatusLineStatsCacheKey(worktreePath: string, options: GitRuntimeOptions = {}): string { - // Why: identical paths can map to different WSL-distro filesystems, so key stats by Git's execution host. - return `${options.wslDistro ?? 'native'}\0${worktreePath}` -} - -/** - * Resolve a submodule's own worktree path from a parent worktree + relative - * submodule path, rejecting anything that escapes the parent. - */ -export function resolveSubmoduleWorktreePath(worktreePath: string, submodulePath: string): string { - if (!submodulePath || submodulePath.includes('\0') || path.isAbsolute(submodulePath)) { - throw new Error('Access denied: invalid submodule path') - } - const resolved = path.resolve(worktreePath, submodulePath) - const rel = path.relative(worktreePath, resolved) - if (!rel || rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) { - throw new Error('Access denied: submodule path escapes the selected worktree') - } - return resolved -} - -/** - * Run a plain status inside a submodule's own worktree (lazy "expand submodule" - * flow). Entry paths are relative to the submodule root; the renderer prefixes them. - */ -export async function getSubmoduleStatus( - worktreePath: string, - submodulePath: string, - options: GetStatusOptions & { staged?: boolean } = {} -): Promise { - const submoduleWorktreePath = resolveSubmoduleWorktreePath(worktreePath, submodulePath) - const limit = resolveGitStatusLimit(options.limit) - // Why: staged expansion only represents HEAD→index; scanning the submodule worktree is wasted work. - const workingResult = options.staged - ? ({ entries: [], conflictOperation: 'unknown' } satisfies GitStatusResult) - : await getStatus(submoduleWorktreePath, options) - // Why: a moved gitlink (clean worktree) has no status rows; surface the parent-commit→checkout range as inner rows. - const fromOid = options.staged - ? await readGitlinkOidFromTree(worktreePath, 'HEAD', submodulePath, options) - : (await readGitlinkOidFromIndex(worktreePath, submodulePath, options)) || - (await readGitlinkOidFromTree(worktreePath, 'HEAD', submodulePath, options)) - const toOid = options.staged - ? await readGitlinkOidFromIndex(worktreePath, submodulePath, options) - : await readWorkingSubmoduleHead(submoduleWorktreePath, options) - if (fromOid && toOid && fromOid !== toOid) { - const rangeEntries = await computeSubmoduleRangeEntries( - submoduleWorktreePath, - fromOid, - toOid, - options - ) - if (options.staged) { - return { ...workingResult, ...capGitStatusEntries(rangeEntries, limit) } - } - const rangePaths = new Set(rangeEntries.map((entry) => entry.path)) - // Range rows win on overlap so the diff matches getDiff's commit-range route. - const entries = [ - ...rangeEntries, - ...workingResult.entries.filter((entry) => !rangePaths.has(entry.path)) - ] - return { - ...workingResult, - ...capGitStatusEntries(entries, limit, workingResult) - } - } - if (options.staged) { - return { ...workingResult, entries: [] } - } - return workingResult -} - -/** - * List files changed between two submodule commits as status rows — used when a - * gitlink pointer moved so the expanded submodule shows committed changes. - */ -async function computeSubmoduleRangeEntries( - submoduleWorktreePath: string, - fromOid: string, - toOid: string, - options: GitRuntimeOptions = {} -): Promise { - const gitOptions = { - ...gitOptionsForWorktree(submoduleWorktreePath, options), - env: gitOptionalLocksDisabledEnv() - } - let nameStatus = '' - let numstat = '' - try { - const [statusResult, numstatResult] = await Promise.all([ - gitExecFileAsync( - ['-c', 'core.quotePath=false', 'diff', '--name-status', '-M', '-C', fromOid, toOid], - gitOptions - ), - gitExecFileAsync( - ['-c', 'core.quotePath=false', 'diff', '-z', '--numstat', '-M', '-C', fromOid, toOid], - gitOptions - ) - ]) - nameStatus = statusResult.stdout - numstat = numstatResult.stdout - } catch { - return [] - } - const statsByPath = parseNumstat(numstat) - const entries: GitStatusEntry[] = [] - for (const line of nameStatus.split(/\r?\n/)) { - if (!line) { - continue - } - const change = parseBranchChangeLine(line) - if (!change) { - continue - } - entries.push({ - path: change.path, - status: change.status, - area: 'unstaged', - ...(change.oldPath ? { oldPath: change.oldPath } : {}), - ...statsByPath.get(change.path) - }) - } - return entries -} - -async function runNumstat( - worktreePath: string, - cached: boolean, - options: GitRuntimeOptions = {} -): Promise | null> { - try { - const { stdout } = await gitExecFileAsync( - [ - '-c', - 'core.quotePath=false', - 'diff', - '-z', - ...(cached ? ['--cached'] : []), - '--numstat', - '-M' - ], - { - ...gitStatusReadOptionsForWorktree(worktreePath, options), - env: gitOptionalLocksDisabledEnv() - } - ) - return parseNumstat(stdout) - } catch (error) { - // Why: an aborted pass must reject; only a genuine numstat failure degrades to uncounted rows. - if (options.signal?.aborted) { - throw error - } - // Why: a numstat failure leaves rows uncounted; null (not empty map) flags the pass incomplete and uncacheable. - return null - } -} - -/** Returns false when a numstat pass failed, so callers skip caching it. */ -async function attachLineStats( - worktreePath: string, - entries: GitStatusEntry[], - options: GitRuntimeOptions = {} -): Promise { - if (entries.length === 0) { - return true - } - const hasStaged = entries.some((entry) => entry.area === 'staged') - const hasUnstaged = entries.some((entry) => entry.area === 'unstaged') - const untrackedPaths = entries - .filter((entry) => entry.area === 'untracked') - .map((entry) => entry.path) - const emptyStats = new Map() - const [stagedStats, unstagedStats, untrackedStats] = await Promise.all([ - hasStaged ? runNumstat(worktreePath, true, options) : Promise.resolve(emptyStats), - hasUnstaged ? runNumstat(worktreePath, false, options) : Promise.resolve(emptyStats), - collectUntrackedAdditions(worktreePath, untrackedPaths, options.signal) - ]) - for (const entry of entries) { - applyLineStats( - entry, - entry.area === 'staged' - ? (stagedStats ?? emptyStats).get(entry.path) - : entry.area === 'unstaged' - ? (unstagedStats ?? emptyStats).get(entry.path) - : untrackedStats.get(entry.path) - ) - } - return stagedStats !== null && unstagedStats !== null -} - -function getShortBranchName(branch: string | undefined): string | null { - const prefix = 'refs/heads/' - return branch?.startsWith(prefix) ? branch.slice(prefix.length) : null -} - -function getEffectiveUpstreamStatusCacheKey( - worktreePath: string, - branchName: string, - upstreamName: string | undefined, - options: GitRuntimeOptions = {} -): string { - return [worktreePath, options.wslDistro ?? 'host', branchName, upstreamName ?? ''].join('\0') -} - -export function clearEffectiveUpstreamNegativeStatusCache(identity: { - worktreePath: string - branchName: string - upstreamName?: string - options?: GitRuntimeOptions -}): void { - const cacheKey = getEffectiveUpstreamStatusCacheKey( - identity.worktreePath, - identity.branchName, - identity.upstreamName, - identity.options - ) - retireEffectiveUpstreamStatusProbe(cacheKey) - effectiveUpstreamStatusCache.delete(cacheKey) - effectiveUpstreamStatusInFlight.delete(cacheKey) - resolvedUpstreamNameCache.delete(cacheKey) - effectiveUpstreamStatusWriteGeneration.set( - cacheKey, - (effectiveUpstreamStatusWriteGeneration.get(cacheKey) ?? 0) + 1 - ) -} - -function retireEffectiveUpstreamStatusProbe(cacheKey: string): void { - const retiredProbe = effectiveUpstreamStatusInFlight.get(cacheKey) - if (!retiredProbe) { - return - } - retiredEffectiveUpstreamStatusInFlight.set(cacheKey, retiredProbe) - void retiredProbe - .finally(() => { - if (retiredEffectiveUpstreamStatusInFlight.get(cacheKey) === retiredProbe) { - retiredEffectiveUpstreamStatusInFlight.delete(cacheKey) - trimEffectiveUpstreamStatusGeneration() - } - }) - .catch(() => undefined) -} - -function hasPendingEffectiveUpstreamStatusProbe(cacheKey: string): boolean { - return ( - effectiveUpstreamStatusInFlight.has(cacheKey) || - retiredEffectiveUpstreamStatusInFlight.has(cacheKey) - ) -} - -function trimEffectiveUpstreamStatusGeneration(): void { - for (const cacheKey of effectiveUpstreamStatusWriteGeneration.keys()) { - if ( - effectiveUpstreamStatusWriteGeneration.size <= MAX_EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_ENTRIES - ) { - break - } - if (hasPendingEffectiveUpstreamStatusProbe(cacheKey)) { - continue - } - effectiveUpstreamStatusWriteGeneration.delete(cacheKey) - } -} - -function readCachedEffectiveUpstreamStatus( - cacheKey: string, - now: number -): GitUpstreamStatus | undefined { - const entry = effectiveUpstreamStatusCache.get(cacheKey) - if (!entry) { - return undefined - } - if (entry.expiresAt <= now) { - effectiveUpstreamStatusCache.delete(cacheKey) - return undefined - } - return entry.status -} - -function rememberEffectiveUpstreamStatus( - cacheKey: string, - status: GitUpstreamStatus, - now: number, - probedSameNameOriginRef: boolean, - writeGeneration: number -): void { - // Why: hasConfiguredPushTarget gates a write action; re-probe each poll rather than cache a stale positive. - if (status.hasUpstream || status.hasConfiguredPushTarget) { - effectiveUpstreamStatusCache.delete(cacheKey) - effectiveUpstreamStatusWriteGeneration.set(cacheKey, writeGeneration + 1) - trimEffectiveUpstreamStatusGeneration() - return - } - if ((effectiveUpstreamStatusWriteGeneration.get(cacheKey) ?? 0) !== writeGeneration) { - return - } - if (!probedSameNameOriginRef) { - return - } - // Why: cache the negative so a stable no-upstream branch doesn't re-probe every poll (TTL lets push/fetch refs appear). - effectiveUpstreamStatusCache.set(cacheKey, { - status, - expiresAt: now + EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_TTL_MS - }) - while (effectiveUpstreamStatusCache.size > MAX_EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_ENTRIES) { - const oldest = effectiveUpstreamStatusCache.keys().next() - if (oldest.done) { - break - } - effectiveUpstreamStatusCache.delete(oldest.value) - effectiveUpstreamStatusWriteGeneration.delete(oldest.value) - } - trimEffectiveUpstreamStatusGeneration() -} - -async function readOrProbeEffectiveUpstreamStatus( - cacheKey: string, - worktreePath: string, - branchName: string, - options: GitRuntimeOptions = {}, - bypassCache = false -): Promise { - if (!bypassCache) { - const cached = readCachedEffectiveUpstreamStatus(cacheKey, Date.now()) - if (cached) { - return cached - } - - const inFlight = effectiveUpstreamStatusInFlight.get(cacheKey) - if (inFlight) { - return inFlight - } - } - - // Why: overlapping refreshes at startup — coalesce the upstream probe so a stable missing ref fails once. - const writeGeneration = effectiveUpstreamStatusWriteGeneration.get(cacheKey) ?? 0 - const probe = probeOrRevalidateEffectiveUpstreamStatus( - cacheKey, - worktreePath, - branchName, - options, - bypassCache - ).then((result) => { - rememberEffectiveUpstreamStatus( - cacheKey, - result.status, - Date.now(), - result.probedSameNameOriginRef, - writeGeneration - ) - return result.status - }) - if (!bypassCache) { - effectiveUpstreamStatusInFlight.set(cacheKey, probe) - } - try { - return await probe - } finally { - if (effectiveUpstreamStatusInFlight.get(cacheKey) === probe) { - effectiveUpstreamStatusInFlight.delete(cacheKey) - trimEffectiveUpstreamStatusGeneration() - } - } -} - -async function probeOrRevalidateEffectiveUpstreamStatus( - cacheKey: string, - worktreePath: string, - branchName: string, - options: GitRuntimeOptions = {}, - bypassCache = false -): Promise<{ status: GitUpstreamStatus; probedSameNameOriginRef: boolean }> { - const now = Date.now() - const cached = resolvedUpstreamNameCache.get(cacheKey) - if (cached && (bypassCache || cached.expiresAt <= now)) { - resolvedUpstreamNameCache.delete(cacheKey) - } else if (cached) { - try { - const status = await getGitUpstreamStatusForUpstreamName( - (args) => gitExecFileAsync(args, gitStatusReadOptionsForWorktree(worktreePath, options)), - cached.upstreamName - ) - return { status, probedSameNameOriginRef: false } - } catch (error) { - // Why: an aborted probe says nothing about the ref; don't evict the warm name cache. - if (options.signal?.aborted) { - throw error - } - // Ref deleted or repo state changed — fall through to a full re-resolve. - resolvedUpstreamNameCache.delete(cacheKey) - } - } - const result = await probeEffectiveUpstreamStatus(worktreePath, branchName, options) - if (result.status.hasUpstream && result.status.upstreamName) { - resolvedUpstreamNameCache.set(cacheKey, { - upstreamName: result.status.upstreamName, - expiresAt: Date.now() + RESOLVED_UPSTREAM_NAME_CACHE_TTL_MS - }) - while (resolvedUpstreamNameCache.size > MAX_EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_ENTRIES) { - const oldest = resolvedUpstreamNameCache.keys().next() - if (oldest.done) { - break - } - resolvedUpstreamNameCache.delete(oldest.value) - } - } - return result -} - -async function probeEffectiveUpstreamStatus( - worktreePath: string, - branchName: string, - options: GitRuntimeOptions = {} -): Promise<{ status: GitUpstreamStatus; probedSameNameOriginRef: boolean }> { - let probedSameNameOriginRef = false - const snapshotRunner = createGitConfigSnapshotRunner((args) => - gitExecFileAsync(args, gitStatusReadOptionsForWorktree(worktreePath, options)) - ) - const status = await getEffectiveGitUpstreamStatus((args) => { - if (args[0] === 'rev-parse' && args.includes(`refs/remotes/origin/${branchName}`)) { - probedSameNameOriginRef = true - } - return snapshotRunner(args) - }) - return { status, probedSameNameOriginRef } -} - -function shouldProbeEffectiveUpstreamStatus( - branch: string | undefined, - upstreamName: string | undefined -): boolean { - const branchName = getShortBranchName(branch) - if (!branchName) { - return false - } - if (!upstreamName) { - return true - } - const parsed = splitRemoteBranchName(upstreamName) - return parsed?.remoteName === 'origin' && parsed.branchName !== branchName -} - -function parseBranchStatusChar(char: string): GitBranchChangeStatus { - switch (char) { - case 'M': - return 'modified' - case 'A': - return 'added' - case 'D': - return 'deleted' - case 'R': - return 'renamed' - case 'C': - return 'copied' - default: - return 'modified' - } -} - -async function parseUnmergedEntry( - worktreePath: string, - line: string -): Promise { - // Why: porcelain v2 `u` records are space-separated (not tab); path is field 10+ and may contain spaces, so join the tail. - const parts = line.split(' ') - const xy = parts[1] - const modeStage1 = parts[3] - const modeStage2 = parts[4] - const modeStage3 = parts[5] - const filePath = decodeGitCQuotedPath(parts.slice(10).join(' ')) - if (!filePath) { - return null - } - - // Why: submodule conflicts (mode 160000) are out of scope for v1 — they need different resolution UX. - if ([modeStage1, modeStage2, modeStage3].some((mode) => mode === '160000')) { - return null - } - - const conflictKind = parseConflictKind(xy) - if (!conflictKind) { - return null - } - - // Why: porcelain v2 `u` records lack rename-origin metadata, so oldPath is intentionally omitted. - return { - path: filePath, - area: 'unstaged', - status: await getConflictCompatibilityStatus(worktreePath, filePath, conflictKind), - conflictKind, - conflictStatus: 'unresolved' - } -} - -function parseConflictKind(xy: string): GitConflictKind | null { - switch (xy) { - case 'UU': - return 'both_modified' - case 'AA': - return 'both_added' - case 'DD': - return 'both_deleted' - case 'AU': - return 'added_by_us' - case 'UA': - return 'added_by_them' - case 'DU': - return 'deleted_by_us' - case 'UD': - return 'deleted_by_them' - default: - return null - } -} - -// Why: `status` here is a rendering-compat choice for icon/color plumbing, not semantic; the conflict badge carries the real meaning. -// Why: for deleted_by_*/added_by_* variants Git's result depends on merge strategy, so check the filesystem. -async function getConflictCompatibilityStatus( - worktreePath: string, - filePath: string, - conflictKind: GitConflictKind -): Promise { - if (conflictKind === 'both_modified' || conflictKind === 'both_added') { - return 'modified' - } - - if (conflictKind === 'both_deleted') { - return 'deleted' - } - - try { - return existsSync(path.join(worktreePath, filePath)) ? 'modified' : 'deleted' - } catch { - // Why: on an fs check failure, 'modified' is safer — it keeps the row visible rather than falsely showing 'deleted'. - return 'modified' - } -} - -// Why: the git-status → existsSync race can miss a transient HEAD; fall back to 'unknown' for one poll cycle. -// Why: detect rebase from rebase-merge/ or rebase-apply/ dirs (persist all steps), not REBASE_HEAD (partial, lingers → stale badge). -export async function detectConflictOperation(worktreePath: string): Promise { - const gitDir = await resolveGitDir(worktreePath) - const mergeHead = path.join(gitDir, 'MERGE_HEAD') - const cherryPickHead = path.join(gitDir, 'CHERRY_PICK_HEAD') - const rebaseMergeDir = path.join(gitDir, 'rebase-merge') - const rebaseApplyDir = path.join(gitDir, 'rebase-apply') - - let hasMergeHead = false - let hasCherryPickHead = false - let hasRebaseDir = false - - try { - hasMergeHead = existsSync(mergeHead) - hasCherryPickHead = existsSync(cherryPickHead) - hasRebaseDir = existsSync(rebaseMergeDir) || existsSync(rebaseApplyDir) - } catch { - return 'unknown' - } - - if (hasMergeHead) { - return 'merge' - } - if (hasRebaseDir) { - return 'rebase' - } - if (hasCherryPickHead) { - return 'cherry-pick' - } - return 'unknown' -} - -export async function abortMerge( - worktreePath: string, - options: GitRuntimeOptions = {} -): Promise { - await runWithGitReadCacheInvalidation(() => - gitExecFileAsync(['merge', '--abort'], gitOptionsForWorktree(worktreePath, options)) - ) -} - -export async function abortRebase( - worktreePath: string, - options: GitRuntimeOptions = {} -): Promise { - await runWithGitReadCacheInvalidation(() => - gitExecFileAsync(['rebase', '--abort'], gitOptionsForWorktree(worktreePath, options)) - ) -} - -export async function resolveGitDir(worktreePath: string): Promise { - const dotGitPath = path.join(worktreePath, '.git') - - try { - const dotGitContents = await readFile(dotGitPath, 'utf-8') - const match = dotGitContents.match(/^gitdir:\s*(.+)\s*$/m) - if (match) { - return path.resolve(worktreePath, match[1]) - } - } catch { - // `.git` is likely a directory in a non-worktree checkout. - } - - return dotGitPath -} - -/** - * List configured submodule paths (relative, forward-slash) for a worktree, cached - * briefly. Read from `.gitmodules` to avoid an index-wide `ls-files` scan. - */ -export async function listSubmodulePaths( - worktreePath: string, - options: GitRuntimeOptions = {} -): Promise { - const now = Date.now() - const cacheKey = getSubmodulePathsCacheKey(worktreePath, options) - const cached = getCachedSubmodulePaths(cacheKey, now) - if (cached) { - return cached - } - // Why: prune on misses so removed worktrees don't accumulate; hot hits stay O(1). - pruneExpiredSubmodulePathsCache(now) - const cacheGeneration = submodulePathsCacheGeneration - let paths: string[] = [] - try { - const { stdout } = await gitExecFileAsync( - ['config', '--file', '.gitmodules', '--get-regexp', '^submodule\\..*\\.path$'], - { ...gitOptionsForWorktree(worktreePath, options), env: gitOptionalLocksDisabledEnv() } - ) - paths = stdout - .split(/\r?\n/) - .map((line) => { - const spaceIndex = line.indexOf(' ') - return spaceIndex === -1 - ? '' - : line - .slice(spaceIndex + 1) - .trim() - .replace(/\/+$/, '') - }) - .filter((value) => value.length > 0) - } catch { - // No .gitmodules (or git config failure) — treat as a repo without submodules. - paths = [] - } - if (cacheGeneration === submodulePathsCacheGeneration) { - rememberSubmodulePaths(cacheKey, paths, Date.now()) - } - return paths -} - -/** - * Find the submodule whose root equals or contains `filePath`. Returns the - * submodule path (forward-slash) or null when the path is not in a submodule. - */ -function findContainingSubmodule(submodulePaths: string[], filePath: string): string | null { - const normalized = filePath.replace(/\\/g, '/').replace(/\/+$/, '') - let best: string | null = null - for (const sub of submodulePaths) { - if (normalized === sub || normalized.startsWith(`${sub}/`)) { - // Prefer the longest match to support nested submodule roots. - if (!best || sub.length > best.length) { - best = sub - } - } - } - return best -} - -async function readGitlinkOidFromTree( - worktreePath: string, - ref: string, - submodulePath: string, - options: GitRuntimeOptions -): Promise { - try { - const { stdout } = await gitExecFileAsync(['ls-tree', ref, '--', submodulePath], { - ...gitOptionsForWorktree(worktreePath, options), - env: gitOptionalLocksDisabledEnv() - }) - return stdout.match(/^160000 commit ([0-9a-f]+)\t/m)?.[1] ?? '' - } catch { - return '' - } -} - -async function readGitlinkOidFromIndex( - worktreePath: string, - submodulePath: string, - options: GitRuntimeOptions -): Promise { - try { - const { stdout } = await gitExecFileAsync(['ls-files', '-s', '--', submodulePath], { - ...gitOptionsForWorktree(worktreePath, options), - env: gitOptionalLocksDisabledEnv() - }) - return stdout.match(/^160000 ([0-9a-f]+) /m)?.[1] ?? '' - } catch { - return '' - } -} - -async function readWorkingSubmoduleHead( - submoduleWorktreePath: string, - options: GitRuntimeOptions -): Promise { - try { - const { stdout } = await gitExecFileAsync(['rev-parse', 'HEAD'], { - ...gitOptionsForWorktree(submoduleWorktreePath, options), - env: gitOptionalLocksDisabledEnv() - }) - return stdout.trim() - } catch { - return '' - } -} - -/** - * Synthesize a gitlink pointer diff: Git represents submodule commit changes as a - * one-line `Subproject commit ` swap, so the old/new oids feed the text differ. - */ -async function buildSubmodulePointerDiff( - worktreePath: string, - submodulePath: string, - staged: boolean, - compareAgainstHead: boolean, - options: GitRuntimeOptions, - // Why: default to the validated resolver so every caller is guarded against path escape. - submoduleWorktreePath = resolveSubmoduleWorktreePath(worktreePath, submodulePath) -): Promise { - let leftOid = '' - let rightOid = '' - if (staged) { - leftOid = await readGitlinkOidFromTree(worktreePath, 'HEAD', submodulePath, options) - rightOid = await readGitlinkOidFromIndex(worktreePath, submodulePath, options) - } else if (compareAgainstHead) { - leftOid = await readGitlinkOidFromTree(worktreePath, 'HEAD', submodulePath, options) - rightOid = await readWorkingSubmoduleHead(submoduleWorktreePath, options) - } else { - leftOid = - (await readGitlinkOidFromIndex(worktreePath, submodulePath, options)) || - (await readGitlinkOidFromTree(worktreePath, 'HEAD', submodulePath, options)) - rightOid = await readWorkingSubmoduleHead(submoduleWorktreePath, options) - } - return buildDiffResult( - leftOid ? `Subproject commit ${leftOid}\n` : '', - rightOid ? `Subproject commit ${rightOid}\n` : '', - false, - false, - submodulePath - ) -} - -/** - * Diff a file inside a submodule across two of its commits — used when the parent - * gitlink moved but the submodule worktree is clean (change is committed). - */ -async function buildSubmoduleInnerCommitRangeDiff( - submoduleWorktreePath: string, - innerPath: string, - fromOid: string, - toOid: string, - options: GitRuntimeOptions -): Promise { - let originalContent = '' - let modifiedContent = '' - let originalIsBinary = false - let modifiedIsBinary = false - try { - const left = await readGitBlobAtOidPath(submoduleWorktreePath, fromOid, innerPath, options) - originalContent = left.content - originalIsBinary = left.isBinary - const right = await readGitBlobAtOidPath(submoduleWorktreePath, toOid, innerPath, options) - modifiedContent = right.content - modifiedIsBinary = right.isBinary - } catch { - // Fallback to empty content; a missing blob (add/delete) reads as one side. - } - return buildDiffResult( - originalContent, - modifiedContent, - originalIsBinary, - modifiedIsBinary, - innerPath - ) -} - -/** - * Get original and modified content for diffing a file. - */ -export async function getDiff( - worktreePath: string, - filePath: string, - staged: boolean, - compareAgainstHead = false, - options: GitRuntimeOptions = {} -): Promise { - // Why: register the dedupe synchronously (before any await) so concurrent identical reads coalesce. - return gitDiffReadDedupe.run( - stableInFlightKey([ - 'diff', - worktreePath, - filePath, - staged, - compareAgainstHead, - ...gitRuntimeOptionsKey(options) - ]), - () => loadDiff(worktreePath, filePath, staged, compareAgainstHead, options) - ) -} - -async function loadDiff( - worktreePath: string, - filePath: string, - staged: boolean, - compareAgainstHead: boolean, - options: GitRuntimeOptions -): Promise { - // Why: gitlink paths can't be read as blobs, so route submodule diffs explicitly (root → pointer, inner → recurse). - const submodulePaths = await listSubmodulePaths(worktreePath, options) - if (submodulePaths.length > 0) { - const matchedSubmodule = findContainingSubmodule(submodulePaths, filePath) - if (matchedSubmodule) { - // Why: validate the .gitmodules-derived path against the worktree boundary so a crafted one can't escape the repo. - const submoduleWorktreePath = resolveSubmoduleWorktreePath(worktreePath, matchedSubmodule) - const normalizedFilePath = filePath.replace(/\\/g, '/').replace(/\/+$/, '') - if (normalizedFilePath === matchedSubmodule) { - return buildSubmodulePointerDiff( - worktreePath, - matchedSubmodule, - staged, - compareAgainstHead, - options, - submoduleWorktreePath - ) - } - const innerPath = normalizedFilePath.slice(matchedSubmodule.length + 1) - const fromOid = staged - ? await readGitlinkOidFromTree(worktreePath, 'HEAD', matchedSubmodule, options) - : (await readGitlinkOidFromIndex(worktreePath, matchedSubmodule, options)) || - (await readGitlinkOidFromTree(worktreePath, 'HEAD', matchedSubmodule, options)) - const toOid = staged - ? await readGitlinkOidFromIndex(worktreePath, matchedSubmodule, options) - : await readWorkingSubmoduleHead(submoduleWorktreePath, options) - // Why: a moved gitlink with a clean submodule worktree means the change is committed — diff the two commits. - if (fromOid && toOid && fromOid !== toOid) { - return buildSubmoduleInnerCommitRangeDiff( - submoduleWorktreePath, - innerPath, - fromOid, - toOid, - options - ) - } - return getDiff(submoduleWorktreePath, innerPath, staged, compareAgainstHead, options) - } - } - - let originalContent = '' - let modifiedContent = '' - let originalIsBinary = false - let modifiedIsBinary = false - let modifiedDeleted = false - - try { - if (staged) { - // Why concurrent: HEAD and the index are independent `git show` spawns. - // Only this branch qualifies — the unstaged left read chains index→HEAD. - const [leftBlob, rightBlob] = await Promise.all([ - readGitBlobAtOidPath(worktreePath, 'HEAD', filePath, options), - readGitBlobAtIndexPath(worktreePath, filePath, options) - ]) - originalContent = leftBlob.content - originalIsBinary = leftBlob.isBinary - modifiedContent = rightBlob.content - modifiedIsBinary = rightBlob.isBinary - modifiedDeleted = !rightBlob.exists - } else { - // The left chain (index→HEAD) is sequential within itself, but the working - // tree read is a plain fs read that does not depend on it. - const [leftBlob, workingTreeBlob] = await Promise.all([ - compareAgainstHead - ? readGitBlobAtOidPath(worktreePath, 'HEAD', filePath, options) - : readUnstagedLeftBlob(worktreePath, filePath, options), - readWorkingTreeFile(path.join(worktreePath, filePath)) - ]) - originalContent = leftBlob.content - originalIsBinary = leftBlob.isBinary - modifiedContent = workingTreeBlob.content - modifiedIsBinary = workingTreeBlob.isBinary - modifiedDeleted = !workingTreeBlob.exists - } - } catch { - // Fallback - } - - const result = buildDiffResult( - originalContent, - modifiedContent, - originalIsBinary, - modifiedIsBinary, - filePath - ) - // Why: mark a proven deletion so previewers don't mistake a read failure's empty side for one. - if (result.kind === 'binary' && modifiedDeleted) { - return { ...result, modifiedDeleted: true } - } - return result -} - -export async function getBranchCompare( - worktreePath: string, - baseRef: string, - options: GitRuntimeOptions = {} -): Promise { - const summary: GitBranchCompareSummary = { - baseRef, - baseOid: null, - compareRef: 'HEAD', - headOid: null, - mergeBase: null, - changedFiles: 0, - status: 'loading' - } - - // The base-ref probe peels to a commit. Only branch refs are guaranteed to store - // commits; remote-tracking refs may store annotated tags whose raw oid must be preserved. - const reusableProbedOidByRef = new Map() - const { compareRef, headOidResult, baseOidResult } = await readBranchCompareHead({ - readCompareRef: () => resolveCompareRef(worktreePath, options), - resolveBaseRef: () => - // Why: short refs like "origin/main" can collide with a local branch; use the proven remote-tracking ref. - resolveWorktreeAddBaseRef(baseRef, async (qualifiedRef) => { - const oid = await resolveWorktreeBaseCommitOid(worktreePath, qualifiedRef, options) - if (oid !== null && qualifiedRef.startsWith('refs/heads/')) { - reusableProbedOidByRef.set(qualifiedRef, oid) - } - return oid !== null - }), - readHeadOid: () => resolveRefOid(worktreePath, 'HEAD', options), - readBaseOid: (ref) => { - const reusableOid = reusableProbedOidByRef.get(ref) - return reusableOid === undefined - ? resolveRefOid(worktreePath, ref, options) - : Promise.resolve(reusableOid) - } - }) - summary.compareRef = compareRef - - let headOid = '' - let baseOid = '' - if (headOidResult.ok) { - headOid = headOidResult.oid - summary.headOid = headOid - } else { - if (baseOidResult.ok) { - baseOid = baseOidResult.oid - summary.baseOid = baseOid - // Why: an unborn branch (new remote worktree) has no changes yet; a compare error would look broken. - summary.changedFiles = 0 - summary.commitsAhead = 0 - summary.status = 'ready' - return { summary, entries: [] } - } - summary.status = 'unborn-head' - summary.errorMessage = - 'This branch does not have a committed HEAD yet, so compare-to-base is unavailable.' - return { summary, entries: [] } - } - - if (baseOidResult.ok) { - baseOid = baseOidResult.oid - summary.baseOid = baseOid - } else { - summary.status = 'invalid-base' - summary.errorMessage = `Base ref ${baseRef} could not be resolved in this repository.` - return { summary, entries: [] } - } - - let mergeBase = '' - try { - mergeBase = await resolveMergeBase(worktreePath, baseOid, headOid, options) - summary.mergeBase = mergeBase - } catch { - summary.status = 'no-merge-base' - summary.errorMessage = `This branch and ${baseRef} do not share a merge base, so compare-to-base is unavailable.` - return { summary, entries: [] } - } - - try { - const [entries, commitsAhead] = await Promise.all([ - loadBranchChanges(worktreePath, mergeBase, headOid, options), - countAheadCommits(worktreePath, baseOid, headOid, options) - ]) - summary.changedFiles = entries.length - summary.commitsAhead = commitsAhead - summary.status = 'ready' - return { summary, entries } - } catch (error) { - summary.status = 'error' - summary.errorMessage = error instanceof Error ? error.message : 'Failed to load branch compare' - return { summary, entries: [] } - } -} - -export async function getBranchDiff( - worktreePath: string, - args: { - headOid: string - mergeBase: string - filePath: string - oldPath?: string - }, - options: GitRuntimeOptions = {} -): Promise { - return gitDiffReadDedupe.run( - stableInFlightKey([ - 'branchDiff', - worktreePath, - args.headOid, - args.mergeBase, - args.filePath, - args.oldPath ?? null, - ...gitRuntimeOptionsKey(options) - ]), - () => loadBranchDiff(worktreePath, args, options) - ) -} - -async function loadBranchDiff( - worktreePath: string, - args: { - headOid: string - mergeBase: string - filePath: string - oldPath?: string - }, - options: GitRuntimeOptions -): Promise { - try { - const leftPath = args.oldPath ?? args.filePath - // Why concurrent: the two sides are independent `git show` spawns, so awaiting - // them in series doubles the latency of every diff the review panel opens. - const [leftBlob, rightBlob] = await Promise.all([ - readGitBlobAtOidPath(worktreePath, args.mergeBase, leftPath, options), - readGitBlobAtOidPath(worktreePath, args.headOid, args.filePath, options) - ]) - - return buildDiffResult( - leftBlob.content, - rightBlob.content, - leftBlob.isBinary, - rightBlob.isBinary, - args.filePath - ) - } catch { - return { - kind: 'text', - originalContent: '', - modifiedContent: '', - originalIsBinary: false, - modifiedIsBinary: false - } - } -} - -export async function getCommitCompare( - worktreePath: string, - commitId: string, - options: GitRuntimeOptions = {} -): Promise { - let commitOid = '' - try { - commitOid = await resolveRefOid(worktreePath, `${commitId}^{commit}`, options) - } catch { - return { - summary: { - commitOid: '', - parentOid: null, - compareRef: commitId, - baseRef: 'parent', - changedFiles: 0, - status: 'invalid-commit', - errorMessage: `Commit ${commitId} could not be resolved in this repository.` - }, - entries: [] - } - } - - const summary = { - commitOid, - parentOid: null as string | null, - compareRef: commitOid.slice(0, 7), - baseRef: 'empty tree', - changedFiles: 0, - status: 'ready' as const - } - - try { - const { stdout } = await gitExecFileAsync( - ['rev-list', '--parents', '-n', '1', commitOid], - gitOptionsForWorktree(worktreePath, options) - ) - const firstParent = parseGitRevListFirstParentOid(stdout) - summary.parentOid = firstParent - summary.baseRef = firstParent ? firstParent.slice(0, 7) : 'empty tree' - - const entries = await loadCommitChanges(worktreePath, summary.parentOid, commitOid, options) - summary.changedFiles = entries.length - return { summary, entries } - } catch (error) { - return { - summary: { - ...summary, - status: 'error', - errorMessage: error instanceof Error ? error.message : 'Failed to load commit diff' - }, - entries: [] - } - } -} - -export async function getCommitDiff( - worktreePath: string, - args: { - commitOid: string - parentOid?: string | null - filePath: string - oldPath?: string - }, - options: GitRuntimeOptions = {} -): Promise { - return gitDiffReadDedupe.run( - stableInFlightKey([ - 'commitDiff', - worktreePath, - args.commitOid, - args.parentOid ?? null, - args.filePath, - args.oldPath ?? null, - ...gitRuntimeOptionsKey(options) - ]), - () => loadCommitDiff(worktreePath, args, options) - ) -} - -async function loadCommitDiff( - worktreePath: string, - args: { - commitOid: string - parentOid?: string | null - filePath: string - oldPath?: string - }, - options: GitRuntimeOptions -): Promise { - try { - const leftPath = args.oldPath ?? args.filePath - // Why concurrent: the two sides are independent `git show` spawns. A root - // commit has no parent to read, so that side resolves without a spawn. - const [leftBlob, rightBlob] = await Promise.all([ - args.parentOid - ? readGitBlobAtOidPath(worktreePath, args.parentOid, leftPath, options) - : Promise.resolve({ content: '', isBinary: false }), - readGitBlobAtOidPath(worktreePath, args.commitOid, args.filePath, options) - ]) - - return buildDiffResult( - leftBlob.content, - rightBlob.content, - leftBlob.isBinary, - rightBlob.isBinary, - args.filePath - ) - } catch { - return { - kind: 'text', - originalContent: '', - modifiedContent: '', - originalIsBinary: false, - modifiedIsBinary: false - } - } -} - -async function loadBranchChanges( - worktreePath: string, - mergeBase: string, - headOid: string, - options: GitRuntimeOptions = {} -): Promise { - // Why: core.quotePath=false keeps real UTF-8 paths — see getStatus rationale. - const gitOptions = { - ...gitOptionsForWorktree(worktreePath, options), - maxBuffer: MAX_GIT_SHOW_BYTES - } - // Why: both diffs are independent, so run them concurrently instead of serializing. - const [{ stdout }, { stdout: numstat }] = await Promise.all([ - gitExecFileAsync( - ['-c', 'core.quotePath=false', 'diff', '--name-status', '-M', '-C', mergeBase, headOid], - gitOptions - ), - gitExecFileAsync( - ['-c', 'core.quotePath=false', 'diff', '-z', '--numstat', '-M', '-C', mergeBase, headOid], - gitOptions - ) - ]) - const statsByPath = parseNumstat(numstat) - - const entries: GitBranchChangeEntry[] = [] - // Why: split on /\r?\n/ so Git's CRLF output on Windows leaves no trailing \r in paths. - for (const line of stdout.split(/\r?\n/)) { - if (!line) { - continue - } - const entry = parseBranchChangeLine(line) - if (entry) { - entries.push({ ...entry, ...statsByPath.get(entry.path) }) - } - } - return entries -} - -async function loadCommitChanges( - worktreePath: string, - parentOid: string | null, - commitOid: string, - options: GitRuntimeOptions = {} -): Promise { - // Why: root commits have no parent tree; diff-tree --root uses git's empty tree, avoiding a hardcoded hash-format-specific oid. - const args = parentOid - ? ['-c', 'core.quotePath=false', 'diff', '--name-status', '-M', '-C', parentOid, commitOid] - : [ - '-c', - 'core.quotePath=false', - 'diff-tree', - '--root', - '--no-commit-id', - '--name-status', - '-r', - '-M', - '-C', - commitOid - ] - const numstatArgs = parentOid - ? ['-c', 'core.quotePath=false', 'diff', '-z', '--numstat', '-M', '-C', parentOid, commitOid] - : [ - '-c', - 'core.quotePath=false', - 'diff-tree', - '-z', - '--root', - '--no-commit-id', - '--numstat', - '-r', - '-M', - '-C', - commitOid - ] - const gitOptions = { - ...gitOptionsForWorktree(worktreePath, options), - maxBuffer: MAX_GIT_SHOW_BYTES - } - // Why: the two git queries are independent, so run them in parallel. - const [{ stdout }, { stdout: numstat }] = await Promise.all([ - gitExecFileAsync(args, gitOptions), - gitExecFileAsync(numstatArgs, gitOptions) - ]) - const statsByPath = parseNumstat(numstat) - - const entries: GitBranchChangeEntry[] = [] - for (const line of stdout.split(/\r?\n/)) { - if (!line) { - continue - } - const entry = parseBranchChangeLine(line) - if (entry) { - entries.push({ ...entry, ...statsByPath.get(entry.path) }) - } - } - return entries -} - -function parseBranchChangeLine(line: string): GitBranchChangeEntry | null { - const parts = line.split('\t') - const rawStatus = parts[0] ?? '' - const status = parseBranchStatusChar(rawStatus[0] ?? 'M') - - if (rawStatus.startsWith('R') || rawStatus.startsWith('C')) { - const oldPath = decodeGitCQuotedPath(parts[1] ?? '') - const path = decodeGitCQuotedPath(parts[2] ?? '') - if (!path) { - return null - } - return { path, oldPath, status } - } - - const path = decodeGitCQuotedPath(parts[1] ?? '') - if (!path) { - return null - } - - return { path, status } -} - -async function resolveCompareRef( - worktreePath: string, - options: GitRuntimeOptions = {} -): Promise { - try { - const { stdout } = await gitExecFileAsync(['branch', '--show-current'], { - ...gitOptionsForWorktree(worktreePath, options) - }) - const branch = stdout.trim() - return branch || 'HEAD' - } catch { - return 'HEAD' - } -} - -async function resolveRefOid( - worktreePath: string, - ref: string, - options: GitRuntimeOptions = {} -): Promise { - const { stdout } = await gitExecFileAsync(['rev-parse', '--verify', '--end-of-options', ref], { - ...gitOptionsForWorktree(worktreePath, options) - }) - return stdout.trim() -} - -async function resolveMergeBase( - worktreePath: string, - baseOid: string, - headOid: string, - options: GitRuntimeOptions = {} -): Promise { - const { stdout } = await gitExecFileAsync(['merge-base', baseOid, headOid], { - ...gitOptionsForWorktree(worktreePath, options) - }) - return stdout.trim() -} - -async function countAheadCommits( - worktreePath: string, - baseOid: string, - headOid: string, - options: GitRuntimeOptions = {} -): Promise { - const { stdout } = await gitExecFileAsync(['rev-list', '--count', `${baseOid}..${headOid}`], { - ...gitOptionsForWorktree(worktreePath, options) - }) - return Number.parseInt(stdout.trim(), 10) || 0 -} - -async function readUnstagedLeftBlob( - worktreePath: string, - filePath: string, - options: GitRuntimeOptions = {} -): Promise { - const indexBlob = await readGitBlobAtIndexPath(worktreePath, filePath, options) - if (indexBlob.exists) { - return indexBlob - } - - return readGitBlobAtOidPath(worktreePath, 'HEAD', filePath, options) -} - -async function readGitBlobAtIndexPath( - worktreePath: 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}`], { - ...gitOptionsForWorktree(worktreePath, options), - maxBuffer: MAX_GIT_SHOW_BYTES - }) - - return { ...bufferToBlob(stdout, filePath), exists: true } - } catch (error) { - if (isMaxBufferOverflowError(error)) { - return { content: '', isBinary: true, exists: true } - } - return { content: '', isBinary: false, exists: false } - } -} - -async function readGitBlobAtOidPath( - worktreePath: string, - oid: 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', '--end-of-options', `${oid}:${gitPath}`], - { - ...gitOptionsForWorktree(worktreePath, options), - maxBuffer: MAX_GIT_SHOW_BYTES - } - ) - - return { ...bufferToBlob(stdout, filePath), exists: true } - } catch (error) { - if (isMaxBufferOverflowError(error)) { - return { content: '', isBinary: true, exists: true } - } - return { content: '', isBinary: false, exists: false } - } -} - -async function readWorkingTreeFile(filePath: string): Promise { - let fileStat - try { - fileStat = await stat(filePath) - } catch (error) { - // Why: only ENOENT is a real deletion; other stat errors are read failures, not absence. - return { - content: '', - isBinary: false, - exists: (error as NodeJS.ErrnoException)?.code !== 'ENOENT' - } - } - if (!fileStat.isFile()) { - return { content: '', isBinary: false, exists: false } - } - if (fileStat.size > MAX_GIT_SHOW_BYTES) { - // Why: mirror git's maxBuffer cap for working-tree reads so readFile can't pull in huge assets. - return { content: '', isBinary: true, exists: true } - } - try { - const buffer = await readFile(filePath) - return bufferToBlob(buffer, filePath) - } catch { - // Why: the file exists but could not be read — a read failure, not a deletion. - return { content: '', isBinary: false, exists: true } - } -} - -function bufferToBlob(buffer: Buffer, filePath?: string): GitBlobReadResult { - const isBinary = isBinaryBuffer(buffer) - // Return base64 for recognized image formats so the renderer can display them - const isPreviewableBinary = filePath - ? !!PREVIEWABLE_BINARY_MIME_TYPES[path.extname(filePath).toLowerCase()] - : false - return { - content: isBinary - ? isPreviewableBinary - ? buffer.toString('base64') - : '' - : buffer.toString('utf-8'), - isBinary, - exists: true - } -} - -function buildDiffResult( - originalContent: string, - modifiedContent: string, - originalIsBinary: boolean, - modifiedIsBinary: boolean, - filePath?: string -): GitDiffResult { - if (originalIsBinary || modifiedIsBinary) { - const mimeType = filePath - ? PREVIEWABLE_BINARY_MIME_TYPES[path.extname(filePath).toLowerCase()] - : undefined - return { - kind: 'binary', - originalContent, - modifiedContent, - originalIsBinary, - modifiedIsBinary, - // Why: renderer still checks legacy `isImage` before previewing, so set it for PDFs too until the contract is renamed. - ...(mimeType ? { isImage: true, mimeType } : {}) - } as GitDiffResult - } - - // Why: over the render limit, return metadata instead of huge text so the renderer can show fallback UI. - const largeDiffRenderLimit = getLargeDiffRenderLimit({ originalContent, modifiedContent }) - if (largeDiffRenderLimit.limited) { - return { - kind: 'text', - originalContent: '', - modifiedContent: '', - originalIsBinary: false, - modifiedIsBinary: false, - largeDiffRenderLimit - } - } - - return { - kind: 'text', - originalContent, - modifiedContent, - originalIsBinary: false, - modifiedIsBinary: false - } -} - -type GitBlobReadResult = { - content: string - isBinary: boolean - exists: boolean -} - -const PREVIEWABLE_BINARY_MIME_TYPES: Record = { - '.png': 'image/png', - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.gif': 'image/gif', - '.svg': 'image/svg+xml', - '.webp': 'image/webp', - '.bmp': 'image/bmp', - '.ico': 'image/x-icon', - '.pdf': 'application/pdf' -} - -/** - * Stage a file. - */ -export async function stageFile( - worktreePath: string, - filePath: string, - options: GitRuntimeOptions = {} -): Promise { - invalidateGitReadCaches() - try { - await gitExecFileAsync( - ['add', '--', literalPathspec(filePath, options)], - gitOptionsForWorktree(worktreePath, options) - ) - } finally { - invalidateGitReadCaches() - } -} - -/** - * Unstage a file. - */ -export async function unstageFile( - worktreePath: string, - filePath: string, - options: GitRuntimeOptions = {} -): Promise { - invalidateGitReadCaches() - try { - await gitExecFileAsync(['restore', '--staged', '--', literalPathspec(filePath, options)], { - ...gitOptionsForWorktree(worktreePath, options) - }) - } finally { - invalidateGitReadCaches() - } -} - -export async function getStagedCommitContext( - worktreePath: string, - options: GitRuntimeOptions = {} -): Promise { - const branchPromise = gitExecFileAsync(['branch', '--show-current'], { - ...gitOptionsForWorktree(worktreePath, options) - }).catch(() => ({ stdout: '' })) - const summaryPromise = gitExecFileAsync(['diff', '--cached', '--name-status'], { - ...gitOptionsForWorktree(worktreePath, options), - maxBuffer: MAX_STAGED_COMMIT_CONTEXT_BYTES - }) - - const [branchResult, summaryResult] = await Promise.all([branchPromise, summaryPromise]) - const stagedSummary = summaryResult.stdout.trim() - if (!stagedSummary) { - return null - } - - let stagedPatch = '' - try { - const patchResult = await gitExecFileAsync( - ['diff', '--cached', '--patch', '--minimal', '--no-color', '--no-ext-diff'], - { - ...gitOptionsForWorktree(worktreePath, options), - maxBuffer: MAX_STAGED_COMMIT_CONTEXT_BYTES - } - ) - stagedPatch = patchResult.stdout - } catch (error) { - if (!isMaxBufferOverflowError(error)) { - throw error - } - // Why: staged patch is optional context (truncated later anyway); degrade to file-name summary rather than fail. - console.warn( - '[git] Staged patch too large to read; using file summary only:', - describeMaxBufferOverflowError(error) - ) - } - - return { - branch: branchResult.stdout.trim() || null, - stagedSummary, - stagedPatch - } -} - -export async function commitChanges( - worktreePath: string, - message: string, - options: GitRuntimeOptions = {} -): Promise<{ success: boolean; error?: string }> { - invalidateGitReadCaches() - try { - await gitExecFileAsync(['commit', '-m', message], gitOptionsForWorktree(worktreePath, options)) - return { success: true } - } catch (error) { - // Why: useful message may be on stderr (hook/GPG failures) or stdout ("nothing to commit"), so try both then message. - const readStringField = (field: string): string | null => { - if (typeof error === 'object' && error && field in error) { - const v = (error as Record)[field] - if (typeof v === 'string' && v.length > 0) { - return v - } - } - return null - } - const errorMessage = - readStringField('stderr') ?? - readStringField('stdout') ?? - (error instanceof Error ? error.message : 'Commit failed') - return { success: false, error: errorMessage } - } finally { - invalidateGitReadCaches() - } -} - -/** - * Discard working tree changes for a file. - */ -export async function discardChanges( - worktreePath: string, - filePath: string, - options: GitRuntimeOptions = {} -): Promise { - invalidateGitReadCaches() - const resolvedWorktree = path.resolve(worktreePath) - const resolvedTarget = path.resolve(worktreePath, filePath) - try { - if (!isWithinWorktree(path, resolvedWorktree, resolvedTarget)) { - throw new Error(`Path "${filePath}" resolves outside the worktree`) - } - - let tracked = false - try { - await gitExecFileAsync( - ['ls-files', '--error-unmatch', '--', literalPathspec(filePath, options)], - { - ...gitOptionsForWorktree(worktreePath, options) - } - ) - tracked = true - } catch { - // File is not tracked by git - } - - if (tracked) { - await gitExecFileAsync( - ['restore', '--worktree', '--source=HEAD', '--', literalPathspec(filePath, options)], - { - ...gitOptionsForWorktree(worktreePath, options) - } - ) - return - } - - await removeSafeUntrackedDiscardTarget(worktreePath, filePath, (targetPath) => - cleanUntrackedPaths(worktreePath, [targetPath], options) - ) - } finally { - invalidateGitReadCaches() - } -} - -function normalizeGitPathForCompare(filePath: string): string { - return filePath.replace(/\\/g, '/').replace(/\/+$/, '') -} - -function literalPathspec(filePath: string, options: GitRuntimeOptions): string { - // Why: Git inside WSL needs POSIX paths, but host paths must stay literal, so convert backslashes only for WSL. - const runtimePath = options.wslDistro ? filePath.replace(/\\/g, '/') : filePath - return `:(literal)${runtimePath}` -} - -function isTrackedPathSpec(filePath: string, trackedPaths: readonly string[]): boolean { - const normalized = normalizeGitPathForCompare(filePath) - return trackedPaths.some((trackedPath) => { - const normalizedTracked = normalizeGitPathForCompare(trackedPath) - return normalizedTracked === normalized || normalizedTracked.startsWith(`${normalized}/`) - }) -} - -async function listTrackedPathSpecs( - worktreePath: string, - filePaths: readonly string[], - options: GitRuntimeOptions = {} -): Promise { - const trackedPaths: string[] = [] - for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) { - const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) - const { stdout } = await gitExecFileAsync( - ['ls-files', '-z', '--', ...chunk.map((filePath) => literalPathspec(filePath, options))], - { - ...gitOptionsForWorktree(worktreePath, options) - } - ) - // Why: a tracked directory can hold enough paths to exceed the JS argument limit. - for (const trackedPath of stdout.split('\0')) { - if (trackedPath) { - trackedPaths.push(trackedPath) - } - } - } - return trackedPaths -} - -async function cleanUntrackedPaths( - worktreePath: 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((filePath) => literalPathspec(filePath, options))], - { - ...gitOptionsForWorktree(worktreePath, options) - } - ) - } - } -} - -/** - * Discard working tree changes for many paths in a small number of subprocesses. - */ -export async function bulkDiscardChanges( - worktreePath: string, - filePaths: string[], - options: GitRuntimeOptions = {} -): Promise { - invalidateGitReadCaches() - if (filePaths.length === 0) { - return - } - - try { - const resolvedWorktree = path.resolve(worktreePath) - for (const filePath of filePaths) { - const resolvedTarget = path.resolve(worktreePath, filePath) - if (!isWithinWorktree(path, resolvedWorktree, resolvedTarget)) { - throw new Error(`Path "${filePath}" resolves outside the worktree`) - } - } - - const trackedPathSpecs = await listTrackedPathSpecs(worktreePath, filePaths, options) - const trackedPaths = filePaths.filter((filePath) => - isTrackedPathSpec(filePath, trackedPathSpecs) - ) - const untrackedPaths = filePaths.filter( - (filePath) => !isTrackedPathSpec(filePath, trackedPathSpecs) - ) - await removeSafeUntrackedDiscardTargets( - worktreePath, - untrackedPaths, - (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((filePath) => literalPathspec(filePath, options)) - ], - { - ...gitOptionsForWorktree(worktreePath, options) - } - ) - } - } - ) - } finally { - invalidateGitReadCaches() - } -} - -export function isWithinWorktree( - pathApi: Pick, - resolvedWorktree: string, - resolvedTarget: string -): boolean { - const relativeTarget = pathApi.relative(resolvedWorktree, resolvedTarget) - return !( - relativeTarget === '' || - relativeTarget === '..' || - relativeTarget.startsWith(`..${pathApi.sep}`) || - pathApi.isAbsolute(relativeTarget) - ) -} - -/** - * Bulk stage files in batches to avoid E2BIG. - */ -export async function bulkStageFiles( - worktreePath: string, - filePaths: string[], - options: GitRuntimeOptions = {} -): Promise { - invalidateGitReadCaches() - if (filePaths.length === 0) { - return - } - try { - 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((filePath) => literalPathspec(filePath, options))], - gitOptionsForWorktree(worktreePath, options) - ) - } - } finally { - invalidateGitReadCaches() - } -} - -/** - * Bulk unstage files in batches to avoid E2BIG. - */ -export async function bulkUnstageFiles( - worktreePath: string, - filePaths: string[], - options: GitRuntimeOptions = {} -): Promise { - invalidateGitReadCaches() - if (filePaths.length === 0) { - return - } - try { - 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((filePath) => literalPathspec(filePath, options)) - ], - { - ...gitOptionsForWorktree(worktreePath, options) - } - ) - } - } finally { - invalidateGitReadCaches() - } -} diff --git a/src/main/git/submodule-paths.ts b/src/main/git/submodule-paths.ts new file mode 100644 index 00000000000..5d202173a46 --- /dev/null +++ b/src/main/git/submodule-paths.ts @@ -0,0 +1,191 @@ +import * as path from 'node:path' +import { gitExecFileAsync } from './runner' +import { gitOptionalLocksDisabledEnv } from './git-environment-policy' +import type { GitRuntimeOptions } from './git-runtime-options' +import { gitOptionsForWorktree } from './git-runtime-options' + +const SUBMODULE_PATHS_CACHE_TTL_MS = 5_000 +export const MAX_SUBMODULE_PATHS_CACHE_ENTRIES = 512 +type SubmodulePathsCacheEntry = { paths: string[]; expiresAt: number } +const submodulePathsCache = new Map() +let submodulePathsCacheGeneration = 0 + +export function clearSubmodulePathsCacheForTests(): void { + submodulePathsCache.clear() + // Why: bump the generation so a pre-mutation read can't repopulate the invalidated cache. + submodulePathsCacheGeneration += 1 +} + +export function getSubmodulePathsCacheCountForTests(): number { + return submodulePathsCache.size +} + +function getSubmodulePathsCacheKey(worktreePath: string, options: GitRuntimeOptions): string { + // Why: the same path can map to different WSL-distro filesystems, so key the cache by runtime routing. + return [worktreePath, options.wslDistro ?? null].join('\0') +} + +function pruneExpiredSubmodulePathsCache(now: number): void { + for (const [cacheKey, entry] of submodulePathsCache) { + if (entry.expiresAt <= now) { + submodulePathsCache.delete(cacheKey) + } + } +} + +function trimSubmodulePathsCache(): void { + while (submodulePathsCache.size > MAX_SUBMODULE_PATHS_CACHE_ENTRIES) { + const oldestKey = submodulePathsCache.keys().next().value + if (oldestKey === undefined) { + break + } + submodulePathsCache.delete(oldestKey) + } +} + +function getCachedSubmodulePaths(cacheKey: string, now: number): string[] | null { + const cached = submodulePathsCache.get(cacheKey) + if (!cached) { + return null + } + if (cached.expiresAt <= now) { + submodulePathsCache.delete(cacheKey) + return null + } + submodulePathsCache.delete(cacheKey) + submodulePathsCache.set(cacheKey, cached) + return cached.paths +} + +function rememberSubmodulePaths(cacheKey: string, paths: string[], now: number): void { + submodulePathsCache.delete(cacheKey) + submodulePathsCache.set(cacheKey, { paths, expiresAt: now + SUBMODULE_PATHS_CACHE_TTL_MS }) + trimSubmodulePathsCache() +} + +/** + * Resolve a submodule's own worktree path from a parent worktree + relative + * submodule path, rejecting anything that escapes the parent. + */ +export function resolveSubmoduleWorktreePath(worktreePath: string, submodulePath: string): string { + if (!submodulePath || submodulePath.includes('\0') || path.isAbsolute(submodulePath)) { + throw new Error('Access denied: invalid submodule path') + } + const resolved = path.resolve(worktreePath, submodulePath) + const rel = path.relative(worktreePath, resolved) + if (!rel || rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) { + throw new Error('Access denied: submodule path escapes the selected worktree') + } + return resolved +} + +/** + * List configured submodule paths (relative, forward-slash) for a worktree, cached + * briefly. Read from `.gitmodules` to avoid an index-wide `ls-files` scan. + */ +export async function listSubmodulePaths( + worktreePath: string, + options: GitRuntimeOptions = {} +): Promise { + const now = Date.now() + const cacheKey = getSubmodulePathsCacheKey(worktreePath, options) + const cached = getCachedSubmodulePaths(cacheKey, now) + if (cached) { + return cached + } + // Why: prune on misses so removed worktrees don't accumulate; hot hits stay O(1). + pruneExpiredSubmodulePathsCache(now) + const cacheGeneration = submodulePathsCacheGeneration + let paths: string[] = [] + try { + const { stdout } = await gitExecFileAsync( + ['config', '--file', '.gitmodules', '--get-regexp', '^submodule\\..*\\.path$'], + { ...gitOptionsForWorktree(worktreePath, options), env: gitOptionalLocksDisabledEnv() } + ) + paths = stdout + .split(/\r?\n/) + .map((line) => { + const spaceIndex = line.indexOf(' ') + return spaceIndex === -1 + ? '' + : line + .slice(spaceIndex + 1) + .trim() + .replace(/\/+$/, '') + }) + .filter((value) => value.length > 0) + } catch { + // No .gitmodules (or git config failure) — treat as a repo without submodules. + paths = [] + } + if (cacheGeneration === submodulePathsCacheGeneration) { + rememberSubmodulePaths(cacheKey, paths, Date.now()) + } + return paths +} + +/** + * Find the submodule whose root equals or contains `filePath`. Returns the + * submodule path (forward-slash) or null when the path is not in a submodule. + */ +export function findContainingSubmodule(submodulePaths: string[], filePath: string): string | null { + const normalized = filePath.replace(/\\/g, '/').replace(/\/+$/, '') + let best: string | null = null + for (const sub of submodulePaths) { + if (normalized === sub || normalized.startsWith(`${sub}/`)) { + // Prefer the longest match to support nested submodule roots. + if (!best || sub.length > best.length) { + best = sub + } + } + } + return best +} + +export async function readGitlinkOidFromTree( + worktreePath: string, + ref: string, + submodulePath: string, + options: GitRuntimeOptions +): Promise { + try { + const { stdout } = await gitExecFileAsync(['ls-tree', ref, '--', submodulePath], { + ...gitOptionsForWorktree(worktreePath, options), + env: gitOptionalLocksDisabledEnv() + }) + return stdout.match(/^160000 commit ([0-9a-f]+)\t/m)?.[1] ?? '' + } catch { + return '' + } +} + +export async function readGitlinkOidFromIndex( + worktreePath: string, + submodulePath: string, + options: GitRuntimeOptions +): Promise { + try { + const { stdout } = await gitExecFileAsync(['ls-files', '-s', '--', submodulePath], { + ...gitOptionsForWorktree(worktreePath, options), + env: gitOptionalLocksDisabledEnv() + }) + return stdout.match(/^160000 ([0-9a-f]+) /m)?.[1] ?? '' + } catch { + return '' + } +} + +export async function readWorkingSubmoduleHead( + submoduleWorktreePath: string, + options: GitRuntimeOptions +): Promise { + try { + const { stdout } = await gitExecFileAsync(['rev-parse', 'HEAD'], { + ...gitOptionsForWorktree(submoduleWorktreePath, options), + env: gitOptionalLocksDisabledEnv() + }) + return stdout.trim() + } catch { + return '' + } +} diff --git a/src/main/git/submodule-status.ts b/src/main/git/submodule-status.ts new file mode 100644 index 00000000000..14f566bd186 --- /dev/null +++ b/src/main/git/submodule-status.ts @@ -0,0 +1,119 @@ +import type { GitStatusEntry, GitStatusResult } from '../../shared/git-status-types' +import { parseNumstat } from '../../shared/git-uncommitted-line-stats' +import { capGitStatusEntries, resolveGitStatusLimit } from '../../shared/git-status-limit' +import { gitExecFileAsync } from './runner' +import { gitOptionalLocksDisabledEnv } from './git-environment-policy' +import type { GitRuntimeOptions } from './git-runtime-options' +import { gitOptionsForWorktree } from './git-runtime-options' +import type { GetStatusOptions } from './status-query-options' +import { parseBranchChangeLine } from './git-change-entry-parser' +import { + readGitlinkOidFromIndex, + readGitlinkOidFromTree, + readWorkingSubmoduleHead, + resolveSubmoduleWorktreePath +} from './submodule-paths' +import { getStatus } from './status' + +/** + * Run a plain status inside a submodule's own worktree (lazy "expand submodule" + * flow). Entry paths are relative to the submodule root; the renderer prefixes them. + */ +export async function getSubmoduleStatus( + worktreePath: string, + submodulePath: string, + options: GetStatusOptions & { staged?: boolean } = {} +): Promise { + const submoduleWorktreePath = resolveSubmoduleWorktreePath(worktreePath, submodulePath) + const limit = resolveGitStatusLimit(options.limit) + // Why: staged expansion only represents HEAD→index; scanning the submodule worktree is wasted work. + const workingResult = options.staged + ? ({ entries: [], conflictOperation: 'unknown' } satisfies GitStatusResult) + : await getStatus(submoduleWorktreePath, options) + // Why: a moved gitlink (clean worktree) has no status rows; surface the parent-commit→checkout range as inner rows. + const fromOid = options.staged + ? await readGitlinkOidFromTree(worktreePath, 'HEAD', submodulePath, options) + : (await readGitlinkOidFromIndex(worktreePath, submodulePath, options)) || + (await readGitlinkOidFromTree(worktreePath, 'HEAD', submodulePath, options)) + const toOid = options.staged + ? await readGitlinkOidFromIndex(worktreePath, submodulePath, options) + : await readWorkingSubmoduleHead(submoduleWorktreePath, options) + if (fromOid && toOid && fromOid !== toOid) { + const rangeEntries = await computeSubmoduleRangeEntries( + submoduleWorktreePath, + fromOid, + toOid, + options + ) + if (options.staged) { + return { ...workingResult, ...capGitStatusEntries(rangeEntries, limit) } + } + const rangePaths = new Set(rangeEntries.map((entry) => entry.path)) + // Range rows win on overlap so the diff matches getDiff's commit-range route. + const entries = [ + ...rangeEntries, + ...workingResult.entries.filter((entry) => !rangePaths.has(entry.path)) + ] + return { + ...workingResult, + ...capGitStatusEntries(entries, limit, workingResult) + } + } + if (options.staged) { + return { ...workingResult, entries: [] } + } + return workingResult +} + +/** + * List files changed between two submodule commits as status rows — used when a + * gitlink pointer moved so the expanded submodule shows committed changes. + */ +async function computeSubmoduleRangeEntries( + submoduleWorktreePath: string, + fromOid: string, + toOid: string, + options: GitRuntimeOptions = {} +): Promise { + const gitOptions = { + ...gitOptionsForWorktree(submoduleWorktreePath, options), + env: gitOptionalLocksDisabledEnv() + } + let nameStatus = '' + let numstat = '' + try { + const [statusResult, numstatResult] = await Promise.all([ + gitExecFileAsync( + ['-c', 'core.quotePath=false', 'diff', '--name-status', '-M', '-C', fromOid, toOid], + gitOptions + ), + gitExecFileAsync( + ['-c', 'core.quotePath=false', 'diff', '-z', '--numstat', '-M', '-C', fromOid, toOid], + gitOptions + ) + ]) + nameStatus = statusResult.stdout + numstat = numstatResult.stdout + } catch { + return [] + } + const statsByPath = parseNumstat(numstat) + const entries: GitStatusEntry[] = [] + for (const line of nameStatus.split(/\r?\n/)) { + if (!line) { + continue + } + const change = parseBranchChangeLine(line) + if (!change) { + continue + } + entries.push({ + path: change.path, + status: change.status, + area: 'unstaged', + ...(change.oldPath ? { oldPath: change.oldPath } : {}), + ...statsByPath.get(change.path) + }) + } + return entries +} diff --git a/src/main/git/subprocess-lifecycle.ts b/src/main/git/subprocess-lifecycle.ts new file mode 100644 index 00000000000..fa32f951b80 --- /dev/null +++ b/src/main/git/subprocess-lifecycle.ts @@ -0,0 +1,62 @@ +import { + spawnNodeProcess as spawn, + type SpawnedProcess as ChildProcess +} from '../../shared/child-process/node-process-execution' + +export function createAbortError(): Error { + const error = new Error('The operation was aborted.') + error.name = 'AbortError' + return error +} + +const WINDOWS_TREE_KILL_WAIT_MS = 2_000 + +export function killSpawnedCommandTree(child: ChildProcess): Promise { + const pid = child.pid + if (!pid || process.platform !== 'win32') { + child.kill() + return Promise.resolve() + } + return new Promise((resolve) => { + let killer: ChildProcess + try { + // Why: Windows shims/wsl.exe own descendants; wait for /t tree cleanup so a timed-out command can't outlive its probe. + killer = spawn('taskkill', ['/pid', String(pid), '/t', '/f'], { + stdio: 'ignore', + windowsHide: true + }) + if (!killer || typeof killer.unref !== 'function') { + child.kill() + resolve() + return + } + } catch { + child.kill() + resolve() + return + } + let settled = false + let timer: NodeJS.Timeout | null = null + const finish = (fallbackToChildKill: boolean): void => { + if (settled) { + return + } + settled = true + if (timer) { + clearTimeout(timer) + } + killer.removeAllListeners() + if (fallbackToChildKill) { + child.kill() + } + resolve() + } + killer.once('error', () => finish(true)) + killer.once('close', (code) => finish(code !== 0)) + timer = setTimeout(() => { + killer.kill() + finish(true) + }, WINDOWS_TREE_KILL_WAIT_MS) + killer.unref() + }) +} diff --git a/src/main/git/upstream.test.ts b/src/main/git/upstream.test.ts index 701808a8a31..43f4db6d5dd 100644 --- a/src/main/git/upstream.test.ts +++ b/src/main/git/upstream.test.ts @@ -11,7 +11,7 @@ vi.mock('./runner', () => ({ })) import { getUpstreamStatus, invalidateGitUpstreamStatusReads } from './upstream' -import { runWithGitReadCacheInvalidation } from './status' +import { runWithGitReadCacheInvalidation } from './git-read-cache' const missingTrackingRefError = new Error( "fatal: ambiguous argument 'HEAD@{u}': unknown revision or path not in the working tree.\n" + diff --git a/src/main/git/worktree-add-creation-config.test.ts b/src/main/git/worktree-add-creation-config.test.ts index 44394b7728d..61c86481b03 100644 --- a/src/main/git/worktree-add-creation-config.test.ts +++ b/src/main/git/worktree-add-creation-config.test.ts @@ -18,6 +18,7 @@ vi.mock('./runner', () => ({ gitExecFileSync: gitExecFileSyncMock, translateWslOutputPaths: translateWslOutputPathsMock })) +vi.mock('./git-process-launch', () => ({ gitExecFileSync: gitExecFileSyncMock })) // Default: the checkout cannot be renamed aside, so removal deletes it in place. vi.mock('../worktree-trash', () => ({ diff --git a/src/main/git/worktree-add-local-base-refresh.test.ts b/src/main/git/worktree-add-local-base-refresh.test.ts index cec1151e0eb..d1fd9b1990b 100644 --- a/src/main/git/worktree-add-local-base-refresh.test.ts +++ b/src/main/git/worktree-add-local-base-refresh.test.ts @@ -18,6 +18,7 @@ vi.mock('./runner', () => ({ gitExecFileSync: gitExecFileSyncMock, translateWslOutputPaths: translateWslOutputPathsMock })) +vi.mock('./git-process-launch', () => ({ gitExecFileSync: gitExecFileSyncMock })) // Default: the checkout cannot be renamed aside, so removal deletes it in place. vi.mock('../worktree-trash', () => ({ diff --git a/src/main/git/worktree-add-local-base-suggestion.test.ts b/src/main/git/worktree-add-local-base-suggestion.test.ts index a65f77dd77d..45763706f59 100644 --- a/src/main/git/worktree-add-local-base-suggestion.test.ts +++ b/src/main/git/worktree-add-local-base-suggestion.test.ts @@ -18,6 +18,7 @@ vi.mock('./runner', () => ({ gitExecFileSync: gitExecFileSyncMock, translateWslOutputPaths: translateWslOutputPathsMock })) +vi.mock('./git-process-launch', () => ({ gitExecFileSync: gitExecFileSyncMock })) // Default: the checkout cannot be renamed aside, so removal deletes it in place. vi.mock('../worktree-trash', () => ({ diff --git a/src/main/git/worktree-add-timeout-override.test.ts b/src/main/git/worktree-add-timeout-override.test.ts index 97666c09d4a..9bea7c9e8d4 100644 --- a/src/main/git/worktree-add-timeout-override.test.ts +++ b/src/main/git/worktree-add-timeout-override.test.ts @@ -18,6 +18,7 @@ vi.mock('./runner', () => ({ gitExecFileSync: gitExecFileSyncMock, translateWslOutputPaths: translateWslOutputPathsMock })) +vi.mock('./git-process-launch', () => ({ gitExecFileSync: gitExecFileSyncMock })) // Default: the checkout cannot be renamed aside, so removal deletes it in place. vi.mock('../worktree-trash', () => ({ diff --git a/src/main/git/worktree-base-ref-refresh.ts b/src/main/git/worktree-base-ref-refresh.ts new file mode 100644 index 00000000000..f40cef7f2af --- /dev/null +++ b/src/main/git/worktree-base-ref-refresh.ts @@ -0,0 +1,245 @@ +import type { + LocalBaseRefRefreshResult, + LocalBaseRefUpdateSuggestion +} from '../../shared/worktree/base-ref-drift-types' +import { parseGitRevListAheadBehindCounts } from '../../shared/git-rev-list-output' +import { probeWorktreeBaseRefPresence } from './worktree-base-ref-probe' +import { gitExecFileAsync, translateWslOutputPaths } from './runner' +import type { GitWorktreeExecOptions } from './worktree-execution-options' +import { gitWorktreeExecOptions } from './worktree-execution-options' +import type { AddWorktreeOptions } from './worktree-operation-contracts' +import { parseWorktreeList } from './worktree-list-output' + +type Refreshability = + | { + refreshable: true + baseRef: string + localBranch: string + fullRef: string + remoteTrackingRef: string + localOid: string + remoteOid: string + behind: number + ownerWorktreePath?: string + } + | { refreshable: false; result: LocalBaseRefRefreshResult } + +export async function getLocalBaseRefUpdateSuggestion( + repoPath: string, + baseBranch: string, + remoteTrackingRef: string, + remoteTrackingBase?: AddWorktreeOptions['remoteTrackingBase'], + options: GitWorktreeExecOptions = {} +): Promise { + const evaluation = await evaluateRefreshability( + repoPath, + baseBranch, + remoteTrackingRef, + remoteTrackingBase, + options, + (behind) => behind > 0 + ) + if (!evaluation?.refreshable || evaluation.behind <= 0) { + return undefined + } + return { + baseRef: evaluation.baseRef, + localBranch: evaluation.localBranch, + behind: evaluation.behind + } +} + +export async function refreshLocalBaseRef( + repoPath: string, + baseBranch: string, + remoteTrackingRef: string, + remoteTrackingBase?: AddWorktreeOptions['remoteTrackingBase'], + options: GitWorktreeExecOptions = {} +): Promise { + const evaluation = await evaluateRefreshability( + repoPath, + baseBranch, + remoteTrackingRef, + remoteTrackingBase, + options + ) + if (!evaluation) { + return undefined + } + if (!evaluation.refreshable) { + return evaluation.result + } + + const resultBase = { baseRef: evaluation.baseRef, localBranch: evaluation.localBranch } + try { + if (evaluation.ownerWorktreePath) { + const { stdout } = await gitExecFileAsync( + ['worktree', 'list', '--porcelain'], + gitWorktreeExecOptions(repoPath, options) + ) + const owner = parseWorktreeList(translateWslOutputPaths(stdout, repoPath, options)).find( + (worktree) => worktree.branch === evaluation.fullRef + ) + if (!owner || owner.path !== evaluation.ownerWorktreePath) { + return { ...resultBase, status: 'skipped_error' } + } + const { stdout: status } = await gitExecFileAsync( + ['status', '--porcelain', '--untracked-files=no'], + gitWorktreeExecOptions(owner.path, options) + ) + if (status.trim()) { + return { + ...resultBase, + status: 'skipped_dirty_worktree', + ownerWorktreePath: owner.path + } + } + await gitExecFileAsync( + ['reset', '--hard', evaluation.remoteOid], + gitWorktreeExecOptions(owner.path, options) + ) + return { ...resultBase, status: 'updated', ownerWorktreePath: owner.path } + } + await gitExecFileAsync( + ['update-ref', evaluation.fullRef, evaluation.remoteOid, evaluation.localOid], + gitWorktreeExecOptions(repoPath, options) + ) + return { ...resultBase, status: 'updated' } + } catch { + return { ...resultBase, status: 'skipped_error' } + } +} + +async function evaluateRefreshability( + repoPath: string, + baseBranch: string, + remoteTrackingRef: string, + remoteTrackingBase: AddWorktreeOptions['remoteTrackingBase'], + options: GitWorktreeExecOptions, + shouldInspectOwner: (behind: number) => boolean = () => true +): Promise { + const parsed = parseRemoteTrackingLocalBaseRef(baseBranch, remoteTrackingRef, remoteTrackingBase) + if (!parsed) { + return undefined + } + const resultBase = { baseRef: parsed.baseRef, localBranch: parsed.localBranch } + + let drift: { ahead: number; behind: number } + let localOid = '' + let remoteOid = '' + try { + const { stdout } = await gitExecFileAsync( + ['rev-list', '--left-right', '--count', `${parsed.fullRef}...${remoteTrackingRef}`], + gitWorktreeExecOptions(repoPath, options) + ) + const counts = parseGitRevListAheadBehindCounts(stdout) + if (counts.status !== 'ok' || counts.ahead !== 0) { + return { refreshable: false, result: { ...resultBase, status: 'skipped_not_fast_forward' } } + } + if (!shouldInspectOwner(counts.behind)) { + return undefined + } + const local = await gitExecFileAsync( + ['rev-parse', '--verify', `${parsed.fullRef}^{commit}`], + gitWorktreeExecOptions(repoPath, options) + ) + localOid = local.stdout.trim() + if (!localOid) { + return { refreshable: false, result: { ...resultBase, status: 'skipped_not_fast_forward' } } + } + const remote = await gitExecFileAsync( + ['rev-parse', '--verify', `${remoteTrackingRef}^{commit}`], + gitWorktreeExecOptions(repoPath, options) + ) + remoteOid = remote.stdout.trim() + if (!remoteOid) { + return { refreshable: false, result: { ...resultBase, status: 'skipped_not_fast_forward' } } + } + await gitExecFileAsync( + ['merge-base', '--is-ancestor', localOid, remoteOid], + gitWorktreeExecOptions(repoPath, options) + ) + drift = { ahead: counts.ahead, behind: counts.behind } + } catch { + const presence = await probeWorktreeBaseRefPresence( + (args) => gitExecFileAsync(args, gitWorktreeExecOptions(repoPath, options)), + parsed.fullRef + ) + if (presence === 'absent') { + return undefined + } + return { refreshable: false, result: { ...resultBase, status: 'skipped_not_fast_forward' } } + } + + try { + const { stdout } = await gitExecFileAsync( + ['worktree', 'list', '--porcelain'], + gitWorktreeExecOptions(repoPath, options) + ) + const owner = parseWorktreeList(translateWslOutputPaths(stdout, repoPath, options)).find( + (worktree) => worktree.branch === parsed.fullRef + ) + if (owner) { + const { stdout: status } = await gitExecFileAsync( + ['status', '--porcelain', '--untracked-files=no'], + gitWorktreeExecOptions(owner.path, options) + ) + if (status.trim()) { + return { + refreshable: false, + result: { + ...resultBase, + status: 'skipped_dirty_worktree', + ownerWorktreePath: owner.path + } + } + } + return { + refreshable: true, + ...resultBase, + fullRef: parsed.fullRef, + remoteTrackingRef, + localOid, + remoteOid, + behind: drift.behind, + ownerWorktreePath: owner.path + } + } + return { + refreshable: true, + ...resultBase, + fullRef: parsed.fullRef, + remoteTrackingRef, + localOid, + remoteOid, + behind: drift.behind + } + } catch { + return { refreshable: false, result: { ...resultBase, status: 'skipped_error' } } + } +} + +function parseRemoteTrackingLocalBaseRef( + baseBranch: string, + remoteTrackingRef: string, + remoteTrackingBase?: AddWorktreeOptions['remoteTrackingBase'] +): { baseRef: string; localBranch: string; fullRef: string } | undefined { + if (remoteTrackingBase?.ref === remoteTrackingRef) { + return { + baseRef: remoteTrackingBase.base, + localBranch: remoteTrackingBase.branch, + fullRef: `refs/heads/${remoteTrackingBase.branch}` + } + } + const prefix = 'refs/remotes/' + if (!remoteTrackingRef.startsWith(prefix)) { + return undefined + } + const shortRef = remoteTrackingRef.slice(prefix.length) + const slashIndex = shortRef.indexOf('/') + if (slashIndex <= 0) { + return undefined + } + const localBranch = shortRef.slice(slashIndex + 1) + return { baseRef: baseBranch, localBranch, fullRef: `refs/heads/${localBranch}` } +} diff --git a/src/main/git/worktree-branch-cleanup.ts b/src/main/git/worktree-branch-cleanup.ts new file mode 100644 index 00000000000..5cf458346a8 --- /dev/null +++ b/src/main/git/worktree-branch-cleanup.ts @@ -0,0 +1,188 @@ +import { + branchHasNoUnmergedChangesWithLazyTargetRefresh, + getBranchCleanupTargetRefs +} from '../../shared/git-branch-cleanup' +import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' +import { withLocalGitCapabilityCacheForExecution } from './git-capability-state' +import { gitExecFileAsync } from './runner' +import type { GitWorktreeExecOptions } from './worktree-execution-options' +import { gitWorktreeExecOptions } from './worktree-execution-options' +import { parseWorktreeList } from './worktree-list-output' +import type { RemoveWorktreeOptions } from './worktree-operation-contracts' + +export async function deleteBranchAfterWorktreeRemoval( + repoPath: string, + branchName: string, + branchHead: string, + options: RemoveWorktreeOptions +): Promise { + try { + const result = await deleteLocalBranch( + repoPath, + branchName, + options.forceBranchDelete === true, + options + ) + if (result === 'checked-out') { + return {} + } + return {} + } catch (error) { + if (!options.forceBranchDelete && branchHead) { + try { + if (await deleteAlreadyMergedBranch(repoPath, branchName, branchHead, options)) { + return {} + } + } catch (alreadyMergedDeleteError) { + console.warn( + `[git] Failed to delete already-merged local branch "${branchName}" after removing worktree`, + alreadyMergedDeleteError + ) + } + } + console.warn( + `[git] Preserved local branch "${branchName}" after removing worktree (not fully merged)`, + error + ) + return { preservedBranch: { branchName, ...(branchHead ? { head: branchHead } : {}) } } + } +} + +async function deleteLocalBranch( + repoPath: string, + branchName: string, + forceBranchDelete: boolean, + options: GitWorktreeExecOptions +): Promise<'deleted' | 'checked-out'> { + const deleteFlag = forceBranchDelete ? '-D' : '-d' + try { + await gitExecFileAsync( + ['branch', deleteFlag, '--', branchName], + gitWorktreeExecOptions(repoPath, options) + ) + return 'deleted' + } catch (error) { + if (!isBranchCheckedOutInWorktreeError(error)) { + throw error + } + } + + try { + await gitExecFileAsync(['worktree', 'prune'], gitWorktreeExecOptions(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], + gitWorktreeExecOptions(repoPath, options) + ) + return 'deleted' + } catch (error) { + if (isBranchCheckedOutInWorktreeError(error)) { + return 'checked-out' + } + throw error + } +} + +async function deleteAlreadyMergedBranch( + repoPath: string, + branchName: string, + branchHead: string, + options: GitWorktreeExecOptions +): Promise { + const runGit = (args: string[], execOptions?: { stdin?: string }) => + gitExecFileAsync(args, { + ...gitWorktreeExecOptions(repoPath, options), + ...(execOptions?.stdin !== undefined ? { stdin: execOptions.stdin } : {}) + }) + const targetRefs = await getBranchCleanupTargetRefs(runGit, branchName) + const hasNoUnmergedChanges = await withLocalGitCapabilityCacheForExecution( + { cwd: repoPath, wslDistro: options.wslDistro, signal: options.signal }, + (capabilities) => + branchHasNoUnmergedChangesWithLazyTargetRefresh(runGit, branchName, targetRefs, capabilities) + ) + if (!hasNoUnmergedChanges) { + return false + } + await forceDeleteLocalBranch(repoPath, branchName, branchHead, (args, cwd) => + gitExecFileAsync(args, gitWorktreeExecOptions(cwd, options)) + ) + return true +} + +export async function forceDeleteLocalBranch( + repoPath: string, + branchName: string, + expectedHead: string, + runGit: (args: string[], cwd: string) => Promise<{ stdout: string; stderr: string }> = ( + args, + cwd + ) => gitExecFileAsync(args, { cwd }) +): Promise { + if (!branchName || branchName.includes('\0')) { + throw new Error('Invalid branch name') + } + if (!expectedHead) { + throw new Error( + `Cannot force-delete local branch "${branchName}" without the commit Git preserved.` + ) + } + if (await isLocalBranchCheckedOut(repoPath, branchName, runGit)) { + throw new Error(`Local branch "${branchName}" is checked out in another worktree.`) + } + try { + await runGit(['update-ref', '-d', `refs/heads/${branchName}`, expectedHead], repoPath) + } catch { + throw new Error( + `Local branch "${branchName}" changed after the workspace was deleted. Review it before deleting it.` + ) + } + if (await isLocalBranchCheckedOut(repoPath, branchName, runGit)) { + try { + await runGit(['update-ref', `refs/heads/${branchName}`, expectedHead, ''], repoPath) + } catch (restoreError) { + console.warn( + `[git] Failed to restore local branch "${branchName}" after concurrent checkout`, + restoreError + ) + } + throw new Error(`Local branch "${branchName}" is checked out in another worktree.`) + } + try { + await runGit(['config', '--remove-section', `branch.${branchName}`], repoPath) + } catch { + // Best-effort parity with `git branch -D`; stale config is harmless. + } +} + +async function isLocalBranchCheckedOut( + repoPath: string, + branchName: string, + runGit: (args: string[], cwd: string) => Promise<{ stdout: string; stderr: string }> +): Promise { + const { stdout } = await runGit(['worktree', 'list', '--porcelain'], repoPath) + return parseWorktreeList(stdout).some( + (worktree) => worktree.branch.replace(/^refs\/heads\//, '') === branchName + ) +} + +function isBranchCheckedOutInWorktreeError(error: unknown): boolean { + let text = String(error) + 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) + } + text = parts.join('\n') + } + return /cannot delete branch .*(?:used by worktree|checked out)|branch .*is checked out/i.test( + text + ) +} diff --git a/src/main/git/worktree-creation.ts b/src/main/git/worktree-creation.ts new file mode 100644 index 00000000000..c3635c7283b --- /dev/null +++ b/src/main/git/worktree-creation.ts @@ -0,0 +1,145 @@ +import { resolveWorktreeAddBaseRef } from '../../shared/worktree/base-ref' +import type { + LocalBaseRefRefreshResult, + LocalBaseRefUpdateSuggestion +} from '../../shared/worktree/base-ref-drift-types' +import { windowsLongPathGitArgs } from '../../shared/windows-long-path-git-args' +import { hasWorktreeBaseCommitRef } from './worktree-base-ref-probe' +import { getLocalBaseRefUpdateSuggestion, refreshLocalBaseRef } from './worktree-base-ref-refresh' +import { gitExecFileAsync } from './runner' +import { gitWorktreeExecOptions } from './worktree-execution-options' +import type { AddWorktreeOptions, AddWorktreeResult } from './worktree-operation-contracts' + +export async function createWorktreeCheckout( + repoPath: string, + worktreePath: string, + branch: string, + baseBranch: string | undefined, + refreshBaseRef: boolean, + noCheckout: boolean, + options: AddWorktreeOptions, + resolveTimeout: () => number +): Promise { + let localBaseRefRefresh: LocalBaseRefRefreshResult | undefined + let localBaseRefUpdateSuggestion: LocalBaseRefUpdateSuggestion | undefined + const args = [...windowsLongPathGitArgs(repoPath), 'worktree', 'add'] + let effectiveBase: string | undefined + if (noCheckout) { + args.push('--no-checkout') + } + if (options.checkoutExistingBranch) { + args.push(worktreePath, branch) + } else { + args.push('--no-track', '-b', branch, worktreePath) + if (baseBranch) { + effectiveBase = await resolveWorktreeAddBaseRef(baseBranch, (qualifiedRef) => + hasWorktreeBaseCommitRef(repoPath, qualifiedRef, options) + ) + if (refreshBaseRef) { + localBaseRefRefresh = await refreshLocalBaseRef( + repoPath, + baseBranch, + effectiveBase, + options.remoteTrackingBase, + options + ) + } else if (options.suggestLocalBaseRefUpdate) { + localBaseRefUpdateSuggestion = await getLocalBaseRefUpdateSuggestion( + repoPath, + baseBranch, + effectiveBase, + options.remoteTrackingBase, + options + ) + } + args.push(effectiveBase) + } + } + await gitExecFileAsync(args, { + ...gitWorktreeExecOptions(repoPath, options), + timeout: resolveTimeout() + }) + + if (options.checkoutExistingBranch) { + return localBaseRefRefresh ? { localBaseRefRefresh } : {} + } + if (effectiveBase) { + await persistWorktreeCreationBase(worktreePath, branch, effectiveBase, options) + } + await configureAutomaticUpstreamSetup(worktreePath, options) + return { + ...(localBaseRefRefresh ? { localBaseRefRefresh } : {}), + ...(localBaseRefUpdateSuggestion ? { localBaseRefUpdateSuggestion } : {}) + } +} + +export async function clearWorktreeCreationBase( + worktreePath: string, + branch: string, + options: AddWorktreeOptions +): Promise { + try { + await gitExecFileAsync( + ['config', '--local', '--unset-all', `branch.${branch}.base`], + gitWorktreeExecOptions(worktreePath, options) + ) + } catch { + // Best-effort cleanup leaves the original sparse setup error actionable. + } +} + +async function persistWorktreeCreationBase( + worktreePath: string, + branch: string, + effectiveBase: string, + options: AddWorktreeOptions +): Promise { + const configKey = `branch.${branch}.base` + try { + await gitExecFileAsync( + ['config', '--local', '--replace-all', configKey, effectiveBase], + gitWorktreeExecOptions(worktreePath, options) + ) + } catch (error) { + console.warn(`addWorktree: failed to set ${configKey} for ${worktreePath}`, error) + try { + await gitExecFileAsync( + ['config', '--local', '--unset-all', configKey], + gitWorktreeExecOptions(worktreePath, options) + ) + } catch (unsetError) { + console.warn( + `addWorktree: failed to unset stale ${configKey} for ${worktreePath}`, + unsetError + ) + } + } +} + +async function configureAutomaticUpstreamSetup( + worktreePath: string, + options: AddWorktreeOptions +): Promise { + try { + let alreadySet = false + try { + await gitExecFileAsync( + ['config', '--get', 'push.autoSetupRemote'], + gitWorktreeExecOptions(worktreePath, options) + ) + alreadySet = true + } catch (error) { + if (!(typeof error === 'object' && error !== null && 'code' in error && error.code === 1)) { + throw error + } + } + if (!alreadySet) { + await gitExecFileAsync( + ['config', '--local', 'push.autoSetupRemote', 'true'], + gitWorktreeExecOptions(worktreePath, options) + ) + } + } catch (error) { + console.warn(`addWorktree: failed to set push.autoSetupRemote for ${worktreePath}`, error) + } +} diff --git a/src/main/git/worktree-execution-options.ts b/src/main/git/worktree-execution-options.ts new file mode 100644 index 00000000000..35409952075 --- /dev/null +++ b/src/main/git/worktree-execution-options.ts @@ -0,0 +1,17 @@ +export type GitWorktreeExecOptions = { + wslDistro?: string + signal?: AbortSignal + timeout?: number +} + +export function gitWorktreeExecOptions( + cwd: string, + options: GitWorktreeExecOptions = {} +): { cwd: string; wslDistro?: string; signal?: AbortSignal; timeout?: number } { + return { + cwd, + ...(options.wslDistro ? { wslDistro: options.wslDistro } : {}), + ...(options.signal ? { signal: options.signal } : {}), + ...(options.timeout ? { timeout: options.timeout } : {}) + } +} diff --git a/src/main/git/worktree-git-capabilities.test.ts b/src/main/git/worktree-git-capabilities.test.ts index c6d7cdfc6ce..619173a7ffc 100644 --- a/src/main/git/worktree-git-capabilities.test.ts +++ b/src/main/git/worktree-git-capabilities.test.ts @@ -9,9 +9,10 @@ vi.mock('./runner', () => ({ gitExecFileSync: vi.fn(), translateWslOutputPaths: (output: string) => output })) +vi.mock('./git-process-launch', () => ({ gitExecFileSync: vi.fn() })) import { clearGitCapabilityStateForTests } from './git-capability-state' -import { listWorktrees } from './worktree' +import { listWorktrees } from './worktree-listing' const WORKTREE_LIST_OUTPUT = `worktree /repo HEAD abc123 diff --git a/src/main/git/worktree-graph-listing.test.ts b/src/main/git/worktree-graph-listing.test.ts index b626718de69..2681d9e7d43 100644 --- a/src/main/git/worktree-graph-listing.test.ts +++ b/src/main/git/worktree-graph-listing.test.ts @@ -18,6 +18,7 @@ vi.mock('./runner', () => ({ gitExecFileSync: gitExecFileSyncMock, translateWslOutputPaths: translateWslOutputPathsMock })) +vi.mock('./git-process-launch', () => ({ gitExecFileSync: gitExecFileSyncMock })) // Default: the checkout cannot be renamed aside, so removal deletes it in place. vi.mock('../worktree-trash', () => ({ @@ -26,7 +27,7 @@ vi.mock('../worktree-trash', () => ({ scheduleWorktreeTrashDeletion: vi.fn() })) -import { listWorktreeGraph, WORKTREE_LIST_TIMEOUT_MS } from './worktree' +import { listWorktreeGraph, WORKTREE_LIST_TIMEOUT_MS } from './worktree-listing' import { registerWorktreeSuiteHooks } from './worktree-test-harness' registerWorktreeSuiteHooks() diff --git a/src/main/git/worktree-list-output.ts b/src/main/git/worktree-list-output.ts new file mode 100644 index 00000000000..322b4170988 --- /dev/null +++ b/src/main/git/worktree-list-output.ts @@ -0,0 +1,93 @@ +import { decodeGitCQuotedPath } from '../../shared/git-cquoted-path' +import type { GitWorktreeInfo } from '../../shared/worktree/types' + +/** Parse the porcelain output of `git worktree list --porcelain`. */ +export function parseWorktreeList( + output: string, + options: { nulDelimited?: boolean } = {} +): GitWorktreeInfo[] { + const worktrees: GitWorktreeInfo[] = [] + const blocks = options.nulDelimited ? splitNulWorktreeList(output) : splitLineWorktreeList(output) + + for (const lines of blocks) { + if (lines.length === 0) { + continue + } + + let path = '' + let head = '' + let branch = '' + let isBare = false + let isSparse = false + let locked = false + let lockReason = '' + let prunable = false + let prunableReason = '' + + for (const line of lines) { + if (line.startsWith('worktree ')) { + path = line.slice('worktree '.length) + } else if (line.startsWith('HEAD ')) { + head = line.slice('HEAD '.length) + } else if (line.startsWith('branch ')) { + branch = line.slice('branch '.length) + } else if (line === 'bare') { + isBare = true + } else if (line === 'sparse') { + isSparse = true + } else if (line === 'locked' || line.startsWith('locked ')) { + locked = true + const reason = line.slice('locked'.length).trim() + lockReason = options.nulDelimited ? reason : decodeGitCQuotedPath(reason) + } else if (line === 'prunable' || line.startsWith('prunable ')) { + prunable = true + const reason = line.slice('prunable'.length).trim() + prunableReason = options.nulDelimited ? reason : decodeGitCQuotedPath(reason) + } + } + + if (path) { + worktrees.push({ + path, + head, + branch, + isBare, + ...(isSparse ? { isSparse } : {}), + ...(locked ? { locked: true } : {}), + ...(lockReason ? { lockReason } : {}), + ...(prunable ? { prunable: true } : {}), + ...(prunableReason ? { prunableReason } : {}), + isMainWorktree: worktrees.length === 0 + }) + } + } + return worktrees +} + +function splitLineWorktreeList(output: string): string[][] { + return output + .trim() + .split(/\r?\n\r?\n/) + .map((block) => block.trim().split(/\r?\n/)) +} + +function splitNulWorktreeList(output: string): string[][] { + if (!output.includes('\0')) { + return splitLineWorktreeList(output) + } + + const blocks: string[][] = [] + let currentBlock: string[] = [] + for (const field of output.split('\0')) { + if (field) { + currentBlock.push(field) + } else if (currentBlock.length > 0) { + blocks.push(currentBlock) + currentBlock = [] + } + } + if (currentBlock.length > 0) { + blocks.push(currentBlock) + } + return blocks +} diff --git a/src/main/git/worktree-list-paths.test.ts b/src/main/git/worktree-list-paths.test.ts index a248ddb7518..3d8aef6ad8a 100644 --- a/src/main/git/worktree-list-paths.test.ts +++ b/src/main/git/worktree-list-paths.test.ts @@ -3,7 +3,8 @@ import { mkdtemp, realpath, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import * as path from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { listWorktrees, removeWorktree } from './worktree' +import { removeWorktree } from './worktree' +import { listWorktrees } from './worktree-listing' const tempRoots: string[] = [] diff --git a/src/main/git/worktree-list-porcelain.test.ts b/src/main/git/worktree-list-porcelain.test.ts index dd675832769..7481523b4e4 100644 --- a/src/main/git/worktree-list-porcelain.test.ts +++ b/src/main/git/worktree-list-porcelain.test.ts @@ -34,9 +34,10 @@ vi.mock('./runner', () => ({ gitExecFileSync: gitExecFileSyncMock, translateWslOutputPaths: translateWslOutputPathsMock })) +vi.mock('./git-process-launch', () => ({ gitExecFileSync: gitExecFileSyncMock })) -vi.mock('./status', () => ({ - resolveGitDir: resolveGitDirMock, +vi.mock('./conflict-status', () => ({ resolveGitDir: resolveGitDirMock })) +vi.mock('./git-read-cache', () => ({ runWithGitReadCacheInvalidation: (run: () => Promise) => run() })) @@ -52,7 +53,7 @@ import { resetWorktreeRemovalState } from './remove-worktree-test-harness' -import { listWorktrees, WORKTREE_LIST_TIMEOUT_MS } from './worktree' +import { listWorktrees, WORKTREE_LIST_TIMEOUT_MS } from './worktree-listing' const mockGitCommands = createGitCommandMocker(gitExecFileAsyncMock) const getGitCalls = createGitCallReader(gitExecFileAsyncMock) diff --git a/src/main/git/worktree-listing.ts b/src/main/git/worktree-listing.ts new file mode 100644 index 00000000000..6cf6418aae7 --- /dev/null +++ b/src/main/git/worktree-listing.ts @@ -0,0 +1,233 @@ +import { stat } from 'node:fs/promises' +import type { GitWorktreeInfo } from '../../shared/worktree/types' +import { isUnsupportedWorktreeListZError } from '../../shared/git-worktree-command-capabilities' +import { withLocalGitCapabilityCacheForExecution } from './git-capability-state' +import { gitExecFileAsync } from './runner' +import type { GitWorktreeExecOptions } from './worktree-execution-options' +import { normalizeMainWorktreePath, translateWorktreePath } from './worktree-paths' +import { parseWorktreeList } from './worktree-list-output' +import { annotateSparseCheckoutStatus } from './worktree-sparse-checkout' + +export const WORKTREE_LIST_TIMEOUT_MS = 30_000 +const PRUNABLE_EXISTENCE_PROBE_CONCURRENCY = 8 + +async function readWorktreeList( + repoPath: string, + options: GitWorktreeExecOptions = {} +): Promise { + const execOptions = { + cwd: repoPath, + ...options, + timeout: options.timeout ?? WORKTREE_LIST_TIMEOUT_MS + } + return withLocalGitCapabilityCacheForExecution( + { cwd: repoPath, wslDistro: options.wslDistro, signal: options.signal }, + (capabilities) => + capabilities.runWithFallback( + 'worktree-list-z', + async () => { + const { stdout } = await gitExecFileAsync( + ['worktree', 'list', '--porcelain', '-z'], + execOptions + ) + return normalizeMainWorktreePath( + repoPath, + parseWorktreeList(stdout, { nulDelimited: true }), + options + ) + }, + async () => { + const { stdout } = await gitExecFileAsync( + ['worktree', 'list', '--porcelain'], + execOptions + ) + const normalized = await normalizeMainWorktreePath( + repoPath, + parseWorktreeList(stdout), + options + ) + return annotatePrunableByExistence(normalized, repoPath, options) + }, + isUnsupportedWorktreeListZError + ) + ) +} + +async function annotatePrunableByExistence( + worktrees: GitWorktreeInfo[], + repoPath: string, + options: GitWorktreeExecOptions +): Promise { + const annotated = [...worktrees] + let nextIndex = 0 + async function probeNext(): Promise { + while (nextIndex < worktrees.length) { + const index = nextIndex + nextIndex += 1 + const worktree = worktrees[index] + if ( + !worktree || + worktree.isMainWorktree || + worktree.isBare || + worktree.locked || + worktree.prunable + ) { + continue + } + try { + await stat(translateWorktreePath(worktree.path, repoPath, options)) + } catch (error) { + if (getErrorCode(error) === 'ENOENT') { + annotated[index] = { ...worktree, prunable: true } + } + } + } + } + const workerCount = Math.min(PRUNABLE_EXISTENCE_PROBE_CONCURRENCY, worktrees.length) + await Promise.all(Array.from({ length: workerCount }, () => probeNext())) + return annotated +} + +async function readTranslatedWorktreeGraph( + repoPath: string, + options: GitWorktreeExecOptions +): Promise { + return (await readWorktreeList(repoPath, options)).map((worktree) => { + const translatedPath = translateWorktreePath(worktree.path, repoPath, options) + return translatedPath === worktree.path ? worktree : { ...worktree, path: translatedPath } + }) +} + +export async function listWorktreeGraph( + repoPath: string, + options: GitWorktreeExecOptions = {} +): Promise { + try { + return await readTranslatedWorktreeGraph(repoPath, options) + } catch (error) { + if (await shouldTreatListingErrorAsEmpty(repoPath, error)) { + return [] + } + console.warn(`[git/worktree] listWorktreeGraph failed for ${repoPath}:`, error) + return [] + } +} + +const inFlightWorktreeScans = new Map>() +const worktreeScanGenerations = new Map() + +function hasInFlightWorktreeScanForRepo(repoPath: string): boolean { + const keyPrefix = `${repoPath}\0` + for (const key of inFlightWorktreeScans.keys()) { + if (key.startsWith(keyPrefix)) { + return true + } + } + return false +} + +export function bumpWorktreeScanGeneration(repoPath: string): void { + if (!hasInFlightWorktreeScanForRepo(repoPath)) { + return + } + worktreeScanGenerations.set(repoPath, (worktreeScanGenerations.get(repoPath) ?? 0) + 1) +} + +function pruneWorktreeScanGeneration(repoPath: string): void { + if (worktreeScanGenerations.has(repoPath) && !hasInFlightWorktreeScanForRepo(repoPath)) { + worktreeScanGenerations.delete(repoPath) + } +} + +export function getWorktreeScanCacheSizes(): { inFlight: number; generations: number } { + return { inFlight: inFlightWorktreeScans.size, generations: worktreeScanGenerations.size } +} + +export function resetWorktreeScanCache(): void { + inFlightWorktreeScans.clear() + worktreeScanGenerations.clear() +} + +export function listWorktrees( + repoPath: string, + options: GitWorktreeExecOptions = {} +): Promise { + if (options.signal) { + return listWorktreesUnshared(repoPath, options) + } + const generation = worktreeScanGenerations.get(repoPath) ?? 0 + const timeout = options.timeout ?? WORKTREE_LIST_TIMEOUT_MS + const key = `${repoPath}\0${options.wslDistro ?? ''}\0${timeout}\0${generation}` + const inFlight = inFlightWorktreeScans.get(key) + if (inFlight) { + return inFlight + } + const scan = listWorktreesUnshared(repoPath, options).finally(() => { + if (inFlightWorktreeScans.get(key) === scan) { + inFlightWorktreeScans.delete(key) + } + pruneWorktreeScanGeneration(repoPath) + }) + inFlightWorktreeScans.set(key, scan) + return scan +} + +async function listWorktreesUnshared( + repoPath: string, + options: GitWorktreeExecOptions +): Promise { + try { + return annotateSparseCheckoutStatus(await readTranslatedWorktreeGraph(repoPath, options)) + } catch (error) { + if (await shouldTreatListingErrorAsEmpty(repoPath, error)) { + return [] + } + console.warn(`[git/worktree] listWorktrees failed for ${repoPath}:`, error) + return [] + } +} + +export async function listWorktreesStrict( + repoPath: string, + options: GitWorktreeExecOptions = {} +): Promise { + 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) +} + +async function shouldTreatListingErrorAsEmpty(repoPath: string, error: unknown): Promise { + if (getErrorCode(error) === 'ENOENT') { + try { + await stat(repoPath) + } catch (statError) { + if (getErrorCode(statError) === 'ENOENT') { + console.warn(`[git/worktree] repo path missing; skipping worktree list: ${repoPath}`) + return true + } + } + } + return /not a git repository/i.test(getErrorText(error)) +} + +function getErrorCode(error: unknown): string | undefined { + return typeof error === 'object' && error !== null && 'code' in error + ? String((error as { code?: unknown }).code) + : undefined +} + +function getErrorText(error: unknown): string { + if (typeof error !== 'object' || error === null) { + return String(error) + } + 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) + } + return parts.join('\n') +} diff --git a/src/main/git/worktree-move.test.ts b/src/main/git/worktree-move.test.ts index ccfc65e5fc8..8ae6d295ca6 100644 --- a/src/main/git/worktree-move.test.ts +++ b/src/main/git/worktree-move.test.ts @@ -18,6 +18,7 @@ vi.mock('./runner', () => ({ gitExecFileSync: gitExecFileSyncMock, translateWslOutputPaths: translateWslOutputPathsMock })) +vi.mock('./git-process-launch', () => ({ gitExecFileSync: gitExecFileSyncMock })) // Default: the checkout cannot be renamed aside, so removal deletes it in place. vi.mock('../worktree-trash', () => ({ diff --git a/src/main/git/worktree-operation-contracts.ts b/src/main/git/worktree-operation-contracts.ts new file mode 100644 index 00000000000..3c1ac2cbadc --- /dev/null +++ b/src/main/git/worktree-operation-contracts.ts @@ -0,0 +1,31 @@ +import type { + LocalBaseRefRefreshResult, + LocalBaseRefUpdateSuggestion +} from '../../shared/worktree/base-ref-drift-types' +import type { GitWorktreeInfo } from '../../shared/worktree/types' +import type { GitWorktreeExecOptions } from './worktree-execution-options' + +export type AddWorktreeResult = { + localBaseRefRefresh?: LocalBaseRefRefreshResult + localBaseRefUpdateSuggestion?: LocalBaseRefUpdateSuggestion +} + +export type AddWorktreeOptions = GitWorktreeExecOptions & { + checkoutExistingBranch?: boolean + suggestLocalBaseRefUpdate?: boolean + remoteTrackingBase?: { + base: string + branch: string + ref: string + } +} + +export type RemoveWorktreeOptions = GitWorktreeExecOptions & { + deleteBranch?: boolean + forceBranchDelete?: boolean + knownRemovedWorktree?: Pick +} + +export type WorktreeRemovalPreflightOptions = GitWorktreeExecOptions & { + ignoredUntrackedPaths?: readonly string[] +} diff --git a/src/main/git/worktree-paths.ts b/src/main/git/worktree-paths.ts new file mode 100644 index 00000000000..8f3f773ba92 --- /dev/null +++ b/src/main/git/worktree-paths.ts @@ -0,0 +1,124 @@ +import { posix, win32 } from 'node:path' +import type { GitWorktreeInfo } from '../../shared/worktree/types' +import { parseWslUncPath } from '../../shared/wsl-paths' +import { + hasUnsupportedRevParsePathFormatEcho, + isUnsupportedRevParsePathFormatError +} from '../../shared/git-worktree-command-capabilities' +import { withLocalGitCapabilityCacheForExecution } from './git-capability-state' +import type { GitWorktreeExecOptions } from './worktree-execution-options' +import { gitWorktreeExecOptions } from './worktree-execution-options' +import { gitExecFileAsync, translateWslOutputPaths } from './runner' + +export function areWorktreePathsEqual( + leftPath: string, + rightPath: string, + platform = process.platform +): boolean { + if (platform === 'win32' || looksLikeWindowsPath(leftPath) || looksLikeWindowsPath(rightPath)) { + return ( + win32.normalize(win32.resolve(leftPath)).toLowerCase() === + win32.normalize(win32.resolve(rightPath)).toLowerCase() + ) + } + return posix.normalize(posix.resolve(leftPath)) === posix.normalize(posix.resolve(rightPath)) +} + +export function translateWorktreePath( + worktreePath: string, + repoPath: string, + options: GitWorktreeExecOptions = {} +): string { + const prefix = 'worktree ' + const translated = translateWslOutputPaths(`${prefix}${worktreePath}`, repoPath, options) + return translated.startsWith(prefix) ? translated.slice(prefix.length) : worktreePath +} + +export async function normalizeMainWorktreePath( + repoPath: string, + worktrees: GitWorktreeInfo[], + options: GitWorktreeExecOptions = {} +): Promise { + const mainIndex = worktrees.findIndex((worktree) => worktree.isMainWorktree) + const mainWorktree = worktrees[mainIndex] + const wslRepo = parseWslUncPath(repoPath) + const comparablePath = wslRepo ? wslRepo.linuxPath : repoPath + if (!mainWorktree || areWorktreePathsEqual(mainWorktree.path, comparablePath)) { + return worktrees + } + + const location = await readRepoLocation(repoPath, comparablePath, options) + if (!location || !areWorktreePathsEqual(mainWorktree.path, location.commonDir)) { + return worktrees + } + + const normalized = [...worktrees] + normalized[mainIndex] = { ...mainWorktree, path: location.topLevel } + return normalized +} + +function looksLikeWindowsPath(pathValue: string): boolean { + return /^[A-Za-z]:[\\/]/.test(pathValue) || pathValue.startsWith('\\\\') +} + +function resolveRevParsePath(repoPath: string, value: string): string { + if (posix.isAbsolute(value) || win32.isAbsolute(value)) { + return value + } + return looksLikeWindowsPath(repoPath) + ? win32.resolve(repoPath, value) + : posix.resolve(repoPath, value) +} + +type RepoLocation = { topLevel: string; commonDir: string } + +function parseRepoLocation(repoPath: string, output: string): RepoLocation | undefined { + const lines = output + .split('\n') + .map((line) => (line.endsWith('\r') ? line.slice(0, -1) : line)) + .filter((line) => line.length > 0 && !line.startsWith('-')) + if (lines.length < 2) { + return undefined + } + const [topLevel, commonDir] = lines.slice(-2) + return { + topLevel: resolveRevParsePath(repoPath, topLevel), + commonDir: resolveRevParsePath(repoPath, commonDir) + } +} + +async function readRepoLocation( + repoPath: string, + resolveBasePath: string, + options: GitWorktreeExecOptions +): Promise { + try { + return await withLocalGitCapabilityCacheForExecution( + { cwd: repoPath, wslDistro: options.wslDistro, signal: options.signal }, + (capabilities) => + capabilities.runWithFallback( + 'rev-parse-path-format', + async () => { + const { stdout } = await gitExecFileAsync( + ['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'], + gitWorktreeExecOptions(repoPath, options) + ) + if (hasUnsupportedRevParsePathFormatEcho(stdout)) { + capabilities.rememberUnsupported('rev-parse-path-format') + } + return parseRepoLocation(resolveBasePath, stdout) + }, + async () => { + const { stdout } = await gitExecFileAsync( + ['rev-parse', '--show-toplevel', '--git-common-dir'], + gitWorktreeExecOptions(repoPath, options) + ) + return parseRepoLocation(resolveBasePath, stdout) + }, + isUnsupportedRevParsePathFormatError + ) + ) + } catch { + return undefined + } +} diff --git a/src/main/git/worktree-porcelain-parsing.test.ts b/src/main/git/worktree-porcelain-parsing.test.ts index ae5c1ed569f..f21d7263a45 100644 --- a/src/main/git/worktree-porcelain-parsing.test.ts +++ b/src/main/git/worktree-porcelain-parsing.test.ts @@ -18,6 +18,7 @@ vi.mock('./runner', () => ({ gitExecFileSync: gitExecFileSyncMock, translateWslOutputPaths: translateWslOutputPathsMock })) +vi.mock('./git-process-launch', () => ({ gitExecFileSync: gitExecFileSyncMock })) // Default: the checkout cannot be renamed aside, so removal deletes it in place. vi.mock('../worktree-trash', () => ({ @@ -26,7 +27,7 @@ vi.mock('../worktree-trash', () => ({ scheduleWorktreeTrashDeletion: vi.fn() })) -import { parseWorktreeList } from './worktree' +import { parseWorktreeList } from './worktree-list-output' import { registerWorktreeSuiteHooks } from './worktree-test-harness' registerWorktreeSuiteHooks() diff --git a/src/main/git/worktree-registration-repair.ts b/src/main/git/worktree-registration-repair.ts new file mode 100644 index 00000000000..c9ec7b7cfb6 --- /dev/null +++ b/src/main/git/worktree-registration-repair.ts @@ -0,0 +1,42 @@ +import type { RemoveWorktreeOptions } from './worktree-operation-contracts' +import { gitExecFileAsync } from './runner' +import { gitWorktreeExecOptions } from './worktree-execution-options' +import { listWorktreesStrict } from './worktree-listing' +import { areWorktreePathsEqual } from './worktree-paths' + +export const WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS = 30_000 + +export async function clearMissingWorktreeRegistration( + repoPath: string, + worktreePath: string, + options: RemoveWorktreeOptions +): Promise { + const registrationOptions = { + ...options, + timeout: options.timeout ?? WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS + } + try { + // Removing an already-missing directory works at the Git 2.25 baseline and touches one entry. + await gitExecFileAsync( + ['worktree', 'remove', '--force', worktreePath], + gitWorktreeExecOptions(repoPath, registrationOptions) + ) + return + } catch (error) { + console.warn( + `[git] Failed to deregister the moved worktree "${worktreePath}"; pruning instead`, + error + ) + } + + await gitExecFileAsync( + ['worktree', 'prune'], + gitWorktreeExecOptions(repoPath, registrationOptions) + ) + const stillRegistered = (await listWorktreesStrict(repoPath, registrationOptions)).some( + (worktree) => areWorktreePathsEqual(worktree.path, worktreePath) + ) + if (stillRegistered) { + throw new Error(`Git still reports a registration for "${worktreePath}" after pruning it.`) + } +} diff --git a/src/main/git/worktree-removal-preflight.ts b/src/main/git/worktree-removal-preflight.ts new file mode 100644 index 00000000000..9d0b1a6a0e7 --- /dev/null +++ b/src/main/git/worktree-removal-preflight.ts @@ -0,0 +1,59 @@ +import { gitExecFileAsync } from './runner' +import { gitWorktreeExecOptions } from './worktree-execution-options' +import type { WorktreeRemovalPreflightOptions } from './worktree-operation-contracts' + +export const WORKTREE_REMOVAL_PREFLIGHT_TIMEOUT_MS = 30_000 + +export async function assertWorktreeCleanForRemoval( + worktreePath: string, + force = false, + options: WorktreeRemovalPreflightOptions = {} +): Promise { + if (force) { + return + } + + const { ignoredUntrackedPaths = [], ...gitOptions } = options + const useNullTerminatedStatus = ignoredUntrackedPaths.length > 0 + const { stdout } = await gitExecFileAsync( + ['status', '--porcelain', ...(useNullTerminatedStatus ? ['-z'] : []), '--untracked-files=all'], + { + ...gitWorktreeExecOptions(worktreePath, gitOptions), + timeout: gitOptions.timeout ?? WORKTREE_REMOVAL_PREFLIGHT_TIMEOUT_MS + } + ) + const blockingEntries = useNullTerminatedStatus + ? getBlockingUntrackedStatusEntries(stdout, ignoredUntrackedPaths) + : null + if (blockingEntries ? blockingEntries.length === 0 : !stdout.trim()) { + return + } + + const error = new Error('Worktree has uncommitted or untracked changes.') + ;(error as Error & { stdout?: string }).stdout = blockingEntries + ? blockingEntries.join('\n') + : stdout + throw error +} + +function getBlockingUntrackedStatusEntries( + status: string, + ignoredUntrackedPaths: readonly string[] +): string[] { + const ignored = new Set( + ignoredUntrackedPaths + .map((entry) => + entry + .trim() + .replace(/^[\\/]+/, '') + .replace(/\\/g, '/') + ) + .filter((entry) => entry && !entry.split('/').includes('..')) + ) + return status + .split('\0') + .filter(Boolean) + .filter( + (entry) => !(entry.startsWith('?? ') && ignored.has(entry.slice(3).replace(/\\/g, '/'))) + ) +} diff --git a/src/main/git/worktree-removal.ts b/src/main/git/worktree-removal.ts new file mode 100644 index 00000000000..415a3d328d0 --- /dev/null +++ b/src/main/git/worktree-removal.ts @@ -0,0 +1,99 @@ +import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' +import { assertWorktreeUnlockedForRemoval } from '../../shared/worktree/removal' +import { isSubmoduleWorktreeRemovalRefusal } from '../../shared/worktree/submodule-removal' +import { withSpan } from '../observability/tracer' +import { withWorktreeRemoveStageSpan } from '../observability/instrumentation' +import { + moveWorktreeDirectoryToTrash, + restoreWorktreeDirectoryFromTrash, + scheduleWorktreeTrashDeletion +} from '../worktree-trash' +import { parseWslPath } from '../wsl' +import { deleteBranchAfterWorktreeRemoval } from './worktree-branch-cleanup' +import { gitExecFileAsync } from './runner' +import { gitWorktreeExecOptions } from './worktree-execution-options' +import { listWorktrees } from './worktree-listing' +import type { RemoveWorktreeOptions } from './worktree-operation-contracts' +import { areWorktreePathsEqual } from './worktree-paths' +import { clearMissingWorktreeRegistration } from './worktree-registration-repair' +import { assertWorktreeCleanForRemoval } from './worktree-removal-preflight' + +export async function performRemoveWorktree( + repoPath: string, + worktreePath: string, + force: boolean, + options: RemoveWorktreeOptions +): Promise { + const removedWorktree = + options.knownRemovedWorktree ?? + (await listWorktrees(repoPath, options)).find((worktree) => + areWorktreePathsEqual(worktree.path, worktreePath) + ) + const branchName = (removedWorktree?.branch ?? '').replace(/^refs\/heads\//, '') + const branchHead = removedWorktree?.head ?? '' + + assertWorktreeUnlockedForRemoval(removedWorktree) + + if (!(await tryDeferredDirectoryDeletion(repoPath, worktreePath, force, options))) { + const args = ['worktree', 'remove'] + if (force) { + args.push('--force') + } + args.push(worktreePath) + try { + await gitExecFileAsync(args, gitWorktreeExecOptions(repoPath, options)) + } catch (error) { + if (force || !isSubmoduleWorktreeRemovalRefusal(error)) { + throw error + } + await assertWorktreeCleanForRemoval(worktreePath, false, options) + await gitExecFileAsync( + ['worktree', 'remove', '--force', worktreePath], + gitWorktreeExecOptions(repoPath, options) + ) + } + } + + if (!branchName || options.deleteBranch === false) { + return {} + } + return withSpan('worktree.remove.branch_delete', () => + deleteBranchAfterWorktreeRemoval(repoPath, branchName, branchHead, options) + ) +} + +async function tryDeferredDirectoryDeletion( + repoPath: string, + worktreePath: string, + force: boolean, + options: RemoveWorktreeOptions +): Promise { + // WSL-owned checkouts must be deleted inside the distro, never renamed by Windows Node. + if (options.wslDistro || parseWslPath(worktreePath)) { + return false + } + if (!force) { + try { + await assertWorktreeCleanForRemoval(worktreePath, false, options) + } catch { + return false + } + } + + const trashPath = await withWorktreeRemoveStageSpan('trash_rename', 'local', () => + moveWorktreeDirectoryToTrash(worktreePath) + ) + if (!trashPath) { + return false + } + try { + await clearMissingWorktreeRegistration(repoPath, worktreePath, options) + } catch (error) { + if (await restoreWorktreeDirectoryFromTrash(trashPath, worktreePath)) { + return false + } + throw error + } + scheduleWorktreeTrashDeletion(trashPath) + return true +} diff --git a/src/main/git/worktree-remove-branch-deletion.test.ts b/src/main/git/worktree-remove-branch-deletion.test.ts index e00ee48c0e0..1b5fc2053c6 100644 --- a/src/main/git/worktree-remove-branch-deletion.test.ts +++ b/src/main/git/worktree-remove-branch-deletion.test.ts @@ -18,6 +18,7 @@ vi.mock('./runner', () => ({ gitExecFileSync: gitExecFileSyncMock, translateWslOutputPaths: translateWslOutputPathsMock })) +vi.mock('./git-process-launch', () => ({ gitExecFileSync: gitExecFileSyncMock })) // Default: the checkout cannot be renamed aside, so removal deletes it in place. vi.mock('../worktree-trash', () => ({ diff --git a/src/main/git/worktree-scan-cache-sharing.test.ts b/src/main/git/worktree-scan-cache-sharing.test.ts index cee700b8597..d598e1845c7 100644 --- a/src/main/git/worktree-scan-cache-sharing.test.ts +++ b/src/main/git/worktree-scan-cache-sharing.test.ts @@ -18,6 +18,7 @@ vi.mock('./runner', () => ({ gitExecFileSync: gitExecFileSyncMock, translateWslOutputPaths: translateWslOutputPathsMock })) +vi.mock('./git-process-launch', () => ({ gitExecFileSync: gitExecFileSyncMock })) // Default: the checkout cannot be renamed aside, so removal deletes it in place. vi.mock('../worktree-trash', () => ({ @@ -26,14 +27,13 @@ vi.mock('../worktree-trash', () => ({ scheduleWorktreeTrashDeletion: vi.fn() })) +import { moveWorktree, removeWorktree } from './worktree' import { - _getWorktreeScanCacheSizesForTests, - _resetWorktreeScanCacheForTests, + getWorktreeScanCacheSizes as _getWorktreeScanCacheSizesForTests, listWorktrees, - moveWorktree, - removeWorktree, + resetWorktreeScanCache as _resetWorktreeScanCacheForTests, WORKTREE_LIST_TIMEOUT_MS -} from './worktree' +} from './worktree-listing' import { registerWorktreeSuiteHooks } from './worktree-test-harness' registerWorktreeSuiteHooks() diff --git a/src/main/git/worktree-separate-git-dir.test.ts b/src/main/git/worktree-separate-git-dir.test.ts index e116985b8f1..8e860b17d8a 100644 --- a/src/main/git/worktree-separate-git-dir.test.ts +++ b/src/main/git/worktree-separate-git-dir.test.ts @@ -25,8 +25,8 @@ vi.mock('./runner', async () => { } }) -// Imported after vi.mock so worktree.ts binds to the spied runner. -const { listWorktrees } = await import('./worktree') +// Imported after vi.mock so worktree-listing.ts binds to the spied runner. +const { listWorktrees } = await import('./worktree-listing') const tempRoots: string[] = [] diff --git a/src/main/git/worktree-shared-directories.test.ts b/src/main/git/worktree-shared-directories.test.ts index 97671c88fa6..c850cc29db6 100644 --- a/src/main/git/worktree-shared-directories.test.ts +++ b/src/main/git/worktree-shared-directories.test.ts @@ -13,7 +13,7 @@ import { createWorktreeSharedPaths, findExistingWorktreeSymlinkPaths } from '../ipc/worktree-symlinks' -import { assertWorktreeCleanForRemoval } from './worktree' +import { assertWorktreeCleanForRemoval } from './worktree-removal-preflight' import { getStatus } from './status' const git = (args: string[], cwd: string): void => { diff --git a/src/main/git/worktree-sparse-checkout.test.ts b/src/main/git/worktree-sparse-checkout.test.ts index dba4dc5b59e..a988f5f5fc8 100644 --- a/src/main/git/worktree-sparse-checkout.test.ts +++ b/src/main/git/worktree-sparse-checkout.test.ts @@ -3,7 +3,8 @@ import { mkdtemp, mkdir, realpath, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import * as path from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { listWorktrees, parseCoreSparseCheckoutFlag } from './worktree' +import { listWorktrees } from './worktree-listing' +import { parseCoreSparseCheckoutFlag } from './worktree-sparse-checkout' const tempRoots: string[] = [] diff --git a/src/main/git/worktree-sparse-checkout.ts b/src/main/git/worktree-sparse-checkout.ts new file mode 100644 index 00000000000..3fd4671bba8 --- /dev/null +++ b/src/main/git/worktree-sparse-checkout.ts @@ -0,0 +1,133 @@ +import { readFile, stat } from 'node:fs/promises' +import { isAbsolute, join, resolve } from 'node:path' +import type { GitWorktreeInfo } from '../../shared/worktree/types' +import { resolveGitDir } from './conflict-status' + +const DETECTION_CONCURRENCY = 8 + +export async function annotateSparseCheckoutStatus( + worktrees: GitWorktreeInfo[] +): Promise { + const annotated = [...worktrees] + let nextIndex = 0 + + async function detectNext(): Promise { + while (nextIndex < worktrees.length) { + const index = nextIndex + nextIndex += 1 + const worktree = worktrees[index] + if (!worktree || worktree.isBare || worktree.isSparse) { + continue + } + const isSparse = await detectSparseCheckout(worktree.path) + if (isSparse) { + annotated[index] = { ...worktree, isSparse } + } + } + } + + const workerCount = Math.min(DETECTION_CONCURRENCY, worktrees.length) + await Promise.all(Array.from({ length: workerCount }, () => detectNext())) + return annotated +} + +async function detectSparseCheckout(worktreePath: string): Promise { + // The pattern file is the cheap fast-path; disabled sparse checkout leaves it behind, + // so the effective config flag must also be checked. + try { + const gitDir = await resolveGitDir(worktreePath) + const stats = await stat(join(gitDir, 'info', 'sparse-checkout')) + if (!stats.isFile() || stats.size === 0) { + return false + } + return await isSparseCheckoutEnabled(gitDir) + } catch { + return false + } +} + +async function resolveGitCommonDir(gitDir: string): Promise { + try { + const raw = (await readFile(join(gitDir, 'commondir'), 'utf-8')).trim() + if (raw.length > 0) { + return isAbsolute(raw) ? raw : resolve(gitDir, raw) + } + } catch { + // No commondir file means this gitdir is already the common directory. + } + return gitDir +} + +async function isSparseCheckoutEnabled(gitDir: string): Promise { + const commonDir = await resolveGitCommonDir(gitDir) + const sharedConfig = await readGitConfigText(join(commonDir, 'config')) + const sharedFlag = parseCoreSparseCheckoutFlag(sharedConfig) + if (parseGitConfigFlag(sharedConfig, 'extensions', 'worktreeconfig') !== true) { + return sharedFlag ?? false + } + const worktreeConfig = await readGitConfigText(join(gitDir, 'config.worktree')) + return parseCoreSparseCheckoutFlag(worktreeConfig) ?? sharedFlag ?? false +} + +async function readGitConfigText(configPath: string): Promise { + try { + return await readFile(configPath, 'utf-8') + } catch { + return '' + } +} + +export function parseCoreSparseCheckoutFlag(configContent: string): boolean | undefined { + return parseGitConfigFlag(configContent, 'core', 'sparsecheckout') +} + +const SECTION_HEADER = /^\[\s*([A-Za-z0-9.-]+)(\s+"(?:[^"\\]|\\.)*")?\s*\]/ +const ASSIGNMENT = /^([A-Za-z][A-Za-z0-9-]*)\s*(?:=\s*(.*))?$/ + +function parseGitConfigFlag( + configContent: string, + section: string, + key: string +): boolean | undefined { + let inSection = false + let value: boolean | undefined + for (const rawLine of configContent.split(/\r?\n/)) { + let rest = stripGitConfigComment(rawLine).trim() + for (let header = rest.match(SECTION_HEADER); header; header = rest.match(SECTION_HEADER)) { + inSection = header[1].toLowerCase() === section && header[2] === undefined + rest = rest.slice(header[0].length).trim() + } + if (!inSection || rest.length === 0) { + continue + } + const assignment = rest.match(ASSIGNMENT) + if (assignment?.[1].toLowerCase() === key) { + value = parseGitConfigBoolean(assignment[2]) + } + } + return value +} + +function stripGitConfigComment(line: string): string { + let inQuotes = false + for (let index = 0; index < line.length; index += 1) { + const char = line[index] + if (char === '"' && line[index - 1] !== '\\') { + inQuotes = !inQuotes + } else if ((char === '#' || char === ';') && !inQuotes) { + return line.slice(0, index) + } + } + return line +} + +function parseGitConfigBoolean(raw: string | undefined): boolean { + if (raw === undefined) { + return true + } + const value = raw + .trim() + .replace(/^"(.*)"$/, '$1') + .toLowerCase() + return value === 'true' || value === 'yes' || value === 'on' || value === '1' +} diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts index 55c5d33cf79..bc9704cc5b4 100644 --- a/src/main/git/worktree.ts +++ b/src/main/git/worktree.ts @@ -1,125 +1,30 @@ -/* eslint-disable max-lines -- Why: this file keeps git worktree create/remove behavior together so local cleanup and creation invariants stay in one place. */ -import { readFile, stat } from 'node:fs/promises' -import { isAbsolute, join, posix, resolve, win32 } from 'node:path' -import { - branchHasNoUnmergedChangesWithLazyTargetRefresh, - getBranchCleanupTargetRefs -} from '../../shared/git-branch-cleanup' -import { resolveWorktreeAddBaseRef } from '../../shared/worktree/base-ref' -import { withSpan } from '../observability/tracer' -import { withWorktreeRemoveStageSpan } from '../observability/instrumentation' -import { - moveWorktreeDirectoryToTrash, - restoreWorktreeDirectoryFromTrash, - scheduleWorktreeTrashDeletion -} from '../worktree-trash' -import { parseWslPath } from '../wsl' -import { windowsLongPathGitArgs } from '../../shared/windows-long-path-git-args' -import type { - LocalBaseRefRefreshResult, - LocalBaseRefUpdateSuggestion -} from '../../shared/worktree/base-ref-drift-types' import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' -import type { GitWorktreeInfo } from '../../shared/worktree/types' -import { assertWorktreeUnlockedForRemoval } from '../../shared/worktree/removal' -import { isSubmoduleWorktreeRemovalRefusal } from '../../shared/worktree/submodule-removal' -import { decodeGitCQuotedPath } from '../../shared/git-cquoted-path' -import { parseGitRevListAheadBehindCounts } from '../../shared/git-rev-list-output' -import { parseWslUncPath } from '../../shared/wsl-paths' -import { - hasUnsupportedRevParsePathFormatEcho, - isUnsupportedRevParsePathFormatError, - isUnsupportedWorktreeListZError -} from '../../shared/git-worktree-command-capabilities' -import { withLocalGitCapabilityCacheForExecution } from './git-capability-state' -import { gitExecFileAsync, translateWslOutputPaths } from './runner' -import { resolveGitDir, runWithGitReadCacheInvalidation } from './status' -import { hasWorktreeBaseCommitRef, probeWorktreeBaseRefPresence } from './worktree-base-ref-probe' +import { windowsLongPathGitArgs } from '../../shared/windows-long-path-git-args' +import { runWithGitReadCacheInvalidation } from './git-read-cache' +import { gitExecFileAsync } from './runner' +import { clearWorktreeCreationBase, createWorktreeCheckout } from './worktree-creation' +import { gitWorktreeExecOptions } from './worktree-execution-options' +import { bumpWorktreeScanGeneration } from './worktree-listing' +import type { + AddWorktreeOptions, + AddWorktreeResult, + RemoveWorktreeOptions +} from './worktree-operation-contracts' +import { performRemoveWorktree } from './worktree-removal' -export type AddWorktreeResult = { - localBaseRefRefresh?: LocalBaseRefRefreshResult - localBaseRefUpdateSuggestion?: LocalBaseRefUpdateSuggestion -} - -type SparseWorktreeCreateError = Error & { - cleanupFailed?: boolean -} - -export type GitWorktreeExecOptions = { - wslDistro?: string - signal?: AbortSignal - timeout?: number -} - -type WorktreeRemovalPreflightOptions = GitWorktreeExecOptions & { - ignoredUntrackedPaths?: readonly string[] -} - -export type AddWorktreeOptions = GitWorktreeExecOptions & { - checkoutExistingBranch?: boolean - suggestLocalBaseRefUpdate?: boolean - remoteTrackingBase?: { - base: string - branch: string - ref: string - } -} - -export type RemoveWorktreeOptions = GitWorktreeExecOptions & { - deleteBranch?: boolean - forceBranchDelete?: boolean - knownRemovedWorktree?: Pick -} - -type LocalBaseRefRefreshability = - | { - refreshable: true - baseRef: string - localBranch: string - fullRef: string - remoteTrackingRef: string - localOid: string - remoteOid: string - behind: number - ownerWorktreePath?: string - } - | { - refreshable: false - result: LocalBaseRefRefreshResult - } - -const SPARSE_CHECKOUT_DETECTION_CONCURRENCY = 8 - -const PRUNABLE_EXISTENCE_PROBE_CONCURRENCY = 8 - -// Why: bound `git worktree add` so a OneDrive cloud-placeholder stall fails fast (STA-1292); ample for an ordinary large checkout, but not one behind a slow content filter (#12696). -// Doubles as the floor for ORCA_WORKTREE_ADD_TIMEOUT_MS — lowering it to fail faster also lowers the minimum any override can request. export const WORKTREE_ADD_TIMEOUT_MS = 180_000 -// Why: ceiling for ORCA_WORKTREE_ADD_TIMEOUT_MS (#12696) — ~8x the slowest reported checkout (3.5 min). The cost is that a genuine stall now blocks a create for up to 30 min instead of 3. export const WORKTREE_ADD_TIMEOUT_MAX_MS = 30 * 60_000 -export const WORKTREE_REMOVAL_PREFLIGHT_TIMEOUT_MS = 30_000 -export const WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS = 30_000 -// Why: one wedged shared scan otherwise hangs every later list, including create's post-add re-list. -export const WORKTREE_LIST_TIMEOUT_MS = 30_000 -/** - * `ORCA_WORKTREE_ADD_TIMEOUT_MS` clamped into [{@link WORKTREE_ADD_TIMEOUT_MS}, - * {@link WORKTREE_ADD_TIMEOUT_MAX_MS}]; unset, blank, or unparseable yields the default. - * Warns when a non-blank value is rejected or clamped; trimming and fractional truncation are silent. - * `env` is injectable for tests. - */ +/** Resolve the add timeout override, clamped to the supported operational range. */ export function resolveWorktreeAddTimeoutMs(env: NodeJS.ProcessEnv = process.env): number { const raw = env.ORCA_WORKTREE_ADD_TIMEOUT_MS?.trim() const requested = Math.floor(Number(raw)) - // Why: `=300` reads as seconds to most operators, so clamp rather than obey. const resolved = Number.isNaN(requested) ? WORKTREE_ADD_TIMEOUT_MS : Math.min(Math.max(requested, WORKTREE_ADD_TIMEOUT_MS), WORKTREE_ADD_TIMEOUT_MAX_MS) - // Why: an `isNaN` guard here would delete the unparseable-value warning — comparing against NaN is unequal, and that is what catches it. if (raw && resolved !== requested) { const problem = Number.isNaN(requested) - ? // Why: `600_000` copied out of this file is NaN, not out of range — say which. - 'is not a number' + ? 'is not a number' : `is outside [${WORKTREE_ADD_TIMEOUT_MS}, ${WORKTREE_ADD_TIMEOUT_MAX_MS}]ms` console.warn( `[git/worktree] ORCA_WORKTREE_ADD_TIMEOUT_MS="${raw}" ${problem}; using ${resolved}ms` @@ -128,827 +33,7 @@ export function resolveWorktreeAddTimeoutMs(env: NodeJS.ProcessEnv = process.env return resolved } -function gitExecOptions( - cwd: string, - options: GitWorktreeExecOptions = {} -): { cwd: string; wslDistro?: string; signal?: AbortSignal; timeout?: number } { - return { - cwd, - ...(options.wslDistro ? { wslDistro: options.wslDistro } : {}), - ...(options.signal ? { signal: options.signal } : {}), - ...(options.timeout ? { timeout: options.timeout } : {}) - } -} - -function getErrorCode(error: unknown): string | undefined { - return typeof error === 'object' && error !== null && 'code' in error - ? String((error as { code?: unknown }).code) - : undefined -} - -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) - } - return parts.join('\n') - } - return String(error) -} - -function isNotGitRepositoryError(error: unknown): boolean { - return /not a git repository/i.test(getErrorText(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 parseRemoteTrackingLocalBaseRef( - baseBranch: string, - remoteTrackingRef: string, - remoteTrackingBase?: AddWorktreeOptions['remoteTrackingBase'] -): { baseRef: string; localBranch: string; fullRef: string } | undefined { - if (remoteTrackingBase?.ref === remoteTrackingRef) { - return { - baseRef: remoteTrackingBase.base, - localBranch: remoteTrackingBase.branch, - fullRef: `refs/heads/${remoteTrackingBase.branch}` - } - } - - const remoteRefPrefix = 'refs/remotes/' - if (!remoteTrackingRef.startsWith(remoteRefPrefix)) { - return undefined - } - - // Why: only proven remote-tracking refs get refresh status; slash-containing local branches (release/2026) must not fake a "not refreshed" warning. - const shortRemoteRef = remoteTrackingRef.slice(remoteRefPrefix.length) - const slashIndex = shortRemoteRef.indexOf('/') - if (slashIndex <= 0) { - return undefined - } - - const localBranch = shortRemoteRef.slice(slashIndex + 1) - return { - baseRef: baseBranch, - localBranch, - fullRef: `refs/heads/${localBranch}` - } -} - -function parseRevListDrift(output: string): { ahead: number; behind: number } | null { - const counts = parseGitRevListAheadBehindCounts(output) - return counts.status === 'ok' ? { ahead: counts.ahead, behind: counts.behind } : null -} - -async function evaluateLocalBaseRefRefreshability( - repoPath: string, - baseBranch: string, - remoteTrackingRef: string, - remoteTrackingBase?: AddWorktreeOptions['remoteTrackingBase'], - options: GitWorktreeExecOptions = {}, - shouldInspectOwner: (behind: number) => boolean = () => true -): Promise { - const parsed = parseRemoteTrackingLocalBaseRef(baseBranch, remoteTrackingRef, remoteTrackingBase) - if (!parsed) { - return undefined - } - - const resultBase = { baseRef: parsed.baseRef, localBranch: parsed.localBranch } - - let drift: { ahead: number; behind: number } - let localOid = '' - let remoteOid = '' - try { - // Why: advisory and mutating paths must agree on "safe to fast-forward"; `rev-list A...B` proves no local-only commits and how far behind. - const { stdout } = await gitExecFileAsync( - ['rev-list', '--left-right', '--count', `${parsed.fullRef}...${remoteTrackingRef}`], - gitExecOptions(repoPath, options) - ) - const parsedDrift = parseRevListDrift(stdout) - if (!parsedDrift || parsedDrift.ahead !== 0) { - return { refreshable: false, result: { ...resultBase, status: 'skipped_not_fast_forward' } } - } - if (!shouldInspectOwner(parsedDrift.behind)) { - // Why: a current local ref yields no update suggestion, so the advisory path skips OID resolution and owner inspection. - return undefined - } - const { stdout: localOidOutput } = await gitExecFileAsync( - ['rev-parse', '--verify', `${parsed.fullRef}^{commit}`], - gitExecOptions(repoPath, options) - ) - localOid = localOidOutput.trim() - if (!localOid) { - return { refreshable: false, result: { ...resultBase, status: 'skipped_not_fast_forward' } } - } - const { stdout: remoteOidOutput } = await gitExecFileAsync( - ['rev-parse', '--verify', `${remoteTrackingRef}^{commit}`], - 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], - gitExecOptions(repoPath, options) - ) - drift = parsedDrift - } catch { - // Why (#15331): the probes above also fail when refs/heads/ is simply absent; a branch that - // does not exist yet cannot be stale, so report nothing instead of a bogus divergence warning. - // Only a proven absence suppresses: an unusable repo leaves the warning alone. - const presence = await probeWorktreeBaseRefPresence( - (args) => gitExecFileAsync(args, gitExecOptions(repoPath, options)), - parsed.fullRef - ) - if (presence === 'absent') { - return undefined - } - return { refreshable: false, result: { ...resultBase, status: 'skipped_not_fast_forward' } } - } - - try { - // Why: if the local base branch is checked out, only update it when that owner worktree is clean. - const { stdout: worktreeListOutput } = await gitExecFileAsync( - ['worktree', 'list', '--porcelain'], - gitExecOptions(repoPath, options) - ) - const worktrees = parseWorktreeList( - translateWslOutputPaths(worktreeListOutput, repoPath, options) - ) - const ownerWorktree = worktrees.find((wt) => wt.branch === parsed.fullRef) - - if (ownerWorktree) { - const { stdout: status } = await gitExecFileAsync( - ['status', '--porcelain', '--untracked-files=no'], - gitExecOptions(ownerWorktree.path, options) - ) - if (status.trim()) { - return { - refreshable: false, - result: { - ...resultBase, - status: 'skipped_dirty_worktree', - ownerWorktreePath: ownerWorktree.path - } - } - } - return { - refreshable: true, - ...resultBase, - fullRef: parsed.fullRef, - remoteTrackingRef, - localOid, - remoteOid, - behind: drift.behind, - ownerWorktreePath: ownerWorktree.path - } - } - - // Why: localBranch isn't checked out anywhere, so a bare-ref fast-forward is safe; omitting ownerWorktreePath signals the mutating path to take it. - return { - refreshable: true, - ...resultBase, - fullRef: parsed.fullRef, - remoteTrackingRef, - localOid, - remoteOid, - behind: drift.behind - } - } catch { - return { refreshable: false, result: { ...resultBase, status: 'skipped_error' } } - } -} - -async function getLocalBaseRefUpdateSuggestionForWorktreeCreate( - repoPath: string, - baseBranch: string, - remoteTrackingRef: string, - remoteTrackingBase?: AddWorktreeOptions['remoteTrackingBase'], - options: GitWorktreeExecOptions = {} -): Promise { - const evaluation = await evaluateLocalBaseRefRefreshability( - repoPath, - baseBranch, - remoteTrackingRef, - remoteTrackingBase, - options, - (behind) => behind > 0 - ) - if (!evaluation?.refreshable || evaluation.behind <= 0) { - return undefined - } - return { - baseRef: evaluation.baseRef, - localBranch: evaluation.localBranch, - behind: evaluation.behind - } -} - -async function persistWorktreeCreationBase( - worktreePath: string, - branch: string, - effectiveBase: string, - options: GitWorktreeExecOptions = {} -): Promise { - const configKey = `branch.${branch}.base` - try { - await gitExecFileAsync(['config', '--local', '--replace-all', configKey, effectiveBase], { - ...gitExecOptions(worktreePath, options) - }) - } catch (error) { - console.warn(`addWorktree: failed to set ${configKey} for ${worktreePath}`, error) - try { - // Why: reused branch names may carry stale base metadata; if replacement fails, unset it so consumers don't trust stale lineage. - await gitExecFileAsync(['config', '--local', '--unset-all', configKey], { - ...gitExecOptions(worktreePath, options) - }) - } catch (unsetError) { - console.warn( - `addWorktree: failed to unset stale ${configKey} for ${worktreePath}`, - unsetError - ) - } - } -} - -async function unsetWorktreeCreationBase( - worktreePath: string, - branch: string, - options: GitWorktreeExecOptions = {} -): Promise { - try { - await gitExecFileAsync(['config', '--local', '--unset-all', `branch.${branch}.base`], { - ...gitExecOptions(worktreePath, options) - }) - } catch { - // Best-effort cleanup; leave the original sparse-setup error as the actionable failure. - } -} - -function areWorktreePathsEqual( - leftPath: string, - rightPath: string, - platform = process.platform -): boolean { - if (platform === 'win32' || looksLikeWindowsPath(leftPath) || looksLikeWindowsPath(rightPath)) { - return ( - win32.normalize(win32.resolve(leftPath)).toLowerCase() === - win32.normalize(win32.resolve(rightPath)).toLowerCase() - ) - } - return posix.normalize(posix.resolve(leftPath)) === posix.normalize(posix.resolve(rightPath)) -} - -function looksLikeWindowsPath(pathValue: string): boolean { - return /^[A-Za-z]:[\\/]/.test(pathValue) || pathValue.startsWith('\\\\') -} - -function resolveRevParsePath(repoPath: string, value: string): string { - if (posix.isAbsolute(value) || win32.isAbsolute(value)) { - return value - } - // Old git ignores `--path-format=absolute`, so resolve a relative toplevel/git-dir against the scanned repo path. - return looksLikeWindowsPath(repoPath) - ? win32.resolve(repoPath, value) - : posix.resolve(repoPath, value) -} - -type RepoLocation = { topLevel: string; commonDir: string } - -function parseRepoLocation(repoPath: string, output: string): RepoLocation | undefined { - // Old git echoes the unrecognized `--path-format` flag and exits 0, so drop `-`-prefixed lines and - // read the last two path lines (toplevel, git-common-dir); strip only trailing CR — paths may have edge spaces. - const lines = output - .split('\n') - .map((line) => (line.endsWith('\r') ? line.slice(0, -1) : line)) - .filter((line) => line.length > 0 && !line.startsWith('-')) - if (lines.length < 2) { - return undefined - } - const [topLevel, commonDir] = lines.slice(-2) - return { - topLevel: resolveRevParsePath(repoPath, topLevel), - commonDir: resolveRevParsePath(repoPath, commonDir) - } -} - -async function readRepoLocation( - repoPath: string, - resolveBasePath: string, - options: GitWorktreeExecOptions = {} -): Promise { - try { - return await withLocalGitCapabilityCacheForExecution( - { cwd: repoPath, wslDistro: options.wslDistro, signal: options.signal }, - (capabilities) => - capabilities.runWithFallback( - 'rev-parse-path-format', - async () => { - const { stdout } = await gitExecFileAsync( - ['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'], - gitExecOptions(repoPath, options) - ) - if (hasUnsupportedRevParsePathFormatEcho(stdout)) { - // Why: some old Git echoes the unknown option and exits zero; remember that compat signal even though parsing recovers. - capabilities.rememberUnsupported('rev-parse-path-format') - } - return parseRepoLocation(resolveBasePath, stdout) - }, - async () => { - const { stdout } = await gitExecFileAsync( - ['rev-parse', '--show-toplevel', '--git-common-dir'], - gitExecOptions(repoPath, options) - ) - return parseRepoLocation(resolveBasePath, stdout) - }, - isUnsupportedRevParsePathFormatError - ) - ) - } catch { - return undefined - } -} - -async function normalizeMainWorktreePath( - repoPath: string, - worktrees: GitWorktreeInfo[], - options: GitWorktreeExecOptions = {} -): Promise { - const mainIndex = worktrees.findIndex((worktree) => worktree.isMainWorktree) - const mainWorktree = worktrees[mainIndex] - // Why: under WSL, porcelain/rev-parse paths are Linux but repoPath is UNC; compare in Git-output - // space so the early-return matches and we skip a needless rev-parse per poll (runner still gets repoPath). - const wslRepo = parseWslUncPath(repoPath) - const comparablePath = wslRepo ? wslRepo.linuxPath : repoPath - if (!mainWorktree || areWorktreePathsEqual(mainWorktree.path, comparablePath)) { - return worktrees - } - - const location = await readRepoLocation(repoPath, comparablePath, options) - if (!location) { - return worktrees - } - - // Why: only a separate-git-dir/submodule main worktree reports git-common-dir as its path; gate on - // that equality so we don't overwrite a linked worktree's real working root with its own toplevel. - if (!areWorktreePathsEqual(mainWorktree.path, location.commonDir)) { - return worktrees - } - - const normalized = [...worktrees] - normalized[mainIndex] = { ...mainWorktree, path: location.topLevel } - return normalized -} - -/** - * Parse the porcelain output of `git worktree list --porcelain`. - */ -export function parseWorktreeList( - output: string, - options: { nulDelimited?: boolean } = {} -): GitWorktreeInfo[] { - const worktrees: GitWorktreeInfo[] = [] - const blocks = options.nulDelimited ? splitNulWorktreeList(output) : splitLineWorktreeList(output) - - for (const lines of blocks) { - if (lines.length === 0) { - continue - } - - let path = '' - let head = '' - let branch = '' - let isBare = false - let isSparse = false - let locked = false - let lockReason = '' - let prunable = false - let prunableReason = '' - - for (const line of lines) { - if (line.startsWith('worktree ')) { - path = line.slice('worktree '.length) - } else if (line.startsWith('HEAD ')) { - head = line.slice('HEAD '.length) - } else if (line.startsWith('branch ')) { - branch = line.slice('branch '.length) - } else if (line === 'bare') { - isBare = true - } else if (line === 'sparse') { - isSparse = true - } else if (line === 'locked' || line.startsWith('locked ')) { - locked = true - const rawReason = line.slice('locked'.length).trim() - lockReason = options.nulDelimited ? rawReason : decodeGitCQuotedPath(rawReason) - } else if (line === 'prunable' || line.startsWith('prunable ')) { - // Why: Git ≥2.36 flags registrations whose directory is gone; ignoring it shows the stale worktree as live (#8389). - prunable = true - const rawReason = line.slice('prunable'.length).trim() - prunableReason = options.nulDelimited ? rawReason : decodeGitCQuotedPath(rawReason) - } - } - - if (path) { - // `git worktree list` always emits the main working tree first. - worktrees.push({ - path, - head, - branch, - isBare, - ...(isSparse ? { isSparse } : {}), - ...(locked ? { locked: true } : {}), - ...(lockReason ? { lockReason } : {}), - ...(prunable ? { prunable: true } : {}), - ...(prunableReason ? { prunableReason } : {}), - isMainWorktree: worktrees.length === 0 - }) - } - } - - return worktrees -} - -function splitLineWorktreeList(output: string): string[][] { - return output - .trim() - .split(/\r?\n\r?\n/) - .map((block) => block.trim().split(/\r?\n/)) -} - -function splitNulWorktreeList(output: string): string[][] { - if (!output.includes('\0')) { - return splitLineWorktreeList(output) - } - - const blocks: string[][] = [] - let currentBlock: string[] = [] - - for (const field of output.split('\0')) { - if (field) { - currentBlock.push(field) - continue - } - if (currentBlock.length > 0) { - blocks.push(currentBlock) - currentBlock = [] - } - } - - if (currentBlock.length > 0) { - blocks.push(currentBlock) - } - - return blocks -} - -async function readWorktreeList( - repoPath: string, - options: GitWorktreeExecOptions = {} -): Promise { - const execOptions = { - cwd: repoPath, - ...options, - timeout: options.timeout ?? WORKTREE_LIST_TIMEOUT_MS - } - return withLocalGitCapabilityCacheForExecution( - { cwd: repoPath, wslDistro: options.wslDistro, signal: options.signal }, - (capabilities) => - capabilities.runWithFallback( - 'worktree-list-z', - async () => { - const { stdout } = await gitExecFileAsync( - ['worktree', 'list', '--porcelain', '-z'], - execOptions - ) - return normalizeMainWorktreePath( - repoPath, - parseWorktreeList(stdout, { nulDelimited: true }), - options - ) - }, - async () => { - // Why: `-z` preserves worktree paths with newlines but Git <2.36 rejects it; fall back to the line parser. - const { stdout } = await gitExecFileAsync( - ['worktree', 'list', '--porcelain'], - execOptions - ) - const normalized = await normalizeMainWorktreePath( - repoPath, - parseWorktreeList(stdout), - options - ) - // Why: Git <2.31 emits no `prunable`, so probe each linked path for existence instead of trusting - // stale registrations; a harmless backstop on 2.31–2.35 where parseWorktreeList already set it (#8389). - return annotatePrunableByExistence(normalized, repoPath, options) - }, - isUnsupportedWorktreeListZError - ) - ) -} - -async function annotatePrunableByExistence( - worktrees: GitWorktreeInfo[], - repoPath: string, - options: GitWorktreeExecOptions = {} -): Promise { - const annotated = [...worktrees] - let nextIndex = 0 - - async function probeNext(): Promise { - while (nextIndex < worktrees.length) { - const index = nextIndex - nextIndex += 1 - const worktree = worktrees[index] - // Git only prunes linked worktrees, never locked ones (a lock shields a missing dir; `locked` - // parses only on Git >=2.31). A missing main worktree is handled by the repo-level ENOENT paths. - if ( - !worktree || - worktree.isMainWorktree || - worktree.isBare || - worktree.locked || - worktree.prunable - ) { - continue - } - try { - await stat(translateWorktreePath(worktree.path, repoPath, options)) - } catch (err) { - if (getErrorCode(err) === 'ENOENT') { - annotated[index] = { ...worktree, prunable: true } - } - } - } - } - - const workerCount = Math.min(PRUNABLE_EXISTENCE_PROBE_CONCURRENCY, worktrees.length) - await Promise.all(Array.from({ length: workerCount }, () => probeNext())) - return annotated -} - -async function readTranslatedWorktreeGraph( - repoPath: string, - options: GitWorktreeExecOptions = {} -): Promise { - return (await readWorktreeList(repoPath, options)).map((worktree) => { - const translatedPath = translateWorktreePath(worktree.path, repoPath, options) - return translatedPath === worktree.path ? worktree : { ...worktree, path: translatedPath } - }) -} - -export async function listWorktreeGraph( - repoPath: string, - options: GitWorktreeExecOptions = {} -): Promise { - try { - return await readTranslatedWorktreeGraph(repoPath, options) - } catch (err) { - if (getErrorCode(err) === 'ENOENT') { - try { - await stat(repoPath) - } catch (statErr) { - if (getErrorCode(statErr) === 'ENOENT') { - console.warn(`[git/worktree] repo path missing; skipping worktree list: ${repoPath}`) - return [] - } - } - } - if (isNotGitRepositoryError(err)) { - return [] - } - console.warn(`[git/worktree] listWorktreeGraph failed for ${repoPath}:`, err) - return [] - } -} - -// Why: share concurrent `git worktree list` scans, which are expensive on Windows. -const inFlightWorktreeScans = new Map>() - -// Why: mutation generations prevent listings from joining stale scans. -const worktreeScanGenerations = new Map() - -function hasInFlightWorktreeScanForRepo(repoPath: string): boolean { - const keyPrefix = `${repoPath}\0` - for (const key of inFlightWorktreeScans.keys()) { - if (key.startsWith(keyPrefix)) { - return true - } - } - return false -} - -function bumpWorktreeScanGeneration(repoPath: string): void { - // Why: generations only prevent joining a pre-mutation scan; with no active scan, keeping the repo path just leaks completed mutation keys. - if (!hasInFlightWorktreeScanForRepo(repoPath)) { - return - } - worktreeScanGenerations.set(repoPath, (worktreeScanGenerations.get(repoPath) ?? 0) + 1) -} - -function pruneWorktreeScanGeneration(repoPath: string): void { - // Why: keep ordinary scan settlement O(1); only repos invalidated during an active scan need the cross-generation check. - if (!worktreeScanGenerations.has(repoPath)) { - return - } - if (!hasInFlightWorktreeScanForRepo(repoPath)) { - worktreeScanGenerations.delete(repoPath) - } -} - -export function _getWorktreeScanCacheSizesForTests(): { inFlight: number; generations: number } { - return { - inFlight: inFlightWorktreeScans.size, - generations: worktreeScanGenerations.size - } -} - -export function _resetWorktreeScanCacheForTests(): void { - inFlightWorktreeScans.clear() - worktreeScanGenerations.clear() -} - -/** - * List all worktrees for a git repo at the given path. Concurrent calls for - * the same repo share one scan (unless the caller passes an AbortSignal, - * which must only cancel its own scan). - */ -export function listWorktrees( - repoPath: string, - options: GitWorktreeExecOptions = {} -): Promise { - if (options.signal) { - return listWorktreesUnshared(repoPath, options) - } - const generation = worktreeScanGenerations.get(repoPath) ?? 0 - const timeout = options.timeout ?? WORKTREE_LIST_TIMEOUT_MS - // Why: callers with different deadlines cannot safely share which timeout wins the scan. - const key = `${repoPath}\0${options.wslDistro ?? ''}\0${timeout}\0${generation}` - const inFlight = inFlightWorktreeScans.get(key) - if (inFlight) { - return inFlight - } - const scan = listWorktreesUnshared(repoPath, options).finally(() => { - if (inFlightWorktreeScans.get(key) === scan) { - inFlightWorktreeScans.delete(key) - } - pruneWorktreeScanGeneration(repoPath) - }) - inFlightWorktreeScans.set(key, scan) - return scan -} - -async function listWorktreesUnshared( - repoPath: string, - options: GitWorktreeExecOptions = {} -): Promise { - try { - const worktrees = await readTranslatedWorktreeGraph(repoPath, options) - return annotateSparseCheckoutStatus(worktrees) - } catch (err) { - if (getErrorCode(err) === 'ENOENT') { - try { - await stat(repoPath) - } catch (statErr) { - if (getErrorCode(statErr) === 'ENOENT') { - console.warn(`[git/worktree] repo path missing; skipping worktree list: ${repoPath}`) - return [] - } - } - } - if (isNotGitRepositoryError(err)) { - return [] - } - // Why: don't swallow git-compat/repo-state failures — else they resurface as opaque "created but not found in listing" errors. - console.warn(`[git/worktree] listWorktrees failed for ${repoPath}:`, err) - return [] - } -} - -export async function listWorktreesStrict( - repoPath: string, - options: GitWorktreeExecOptions = {} -): Promise { - 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) -} - -async function annotateSparseCheckoutStatus( - worktrees: GitWorktreeInfo[] -): Promise { - const annotated = [...worktrees] - let nextIndex = 0 - - async function detectNext(): Promise { - while (nextIndex < worktrees.length) { - const index = nextIndex - nextIndex += 1 - const worktree = worktrees[index] - if (!worktree || worktree.isBare || worktree.isSparse) { - continue - } - const isSparse = await detectSparseCheckout(worktree.path) - if (isSparse) { - annotated[index] = { ...worktree, isSparse } - } - } - } - - // Why: cap concurrency so status-poll refreshes don't fan out many sparse-checkout filesystem probes at once. - const workerCount = Math.min(SPARSE_CHECKOUT_DETECTION_CONCURRENCY, worktrees.length) - await Promise.all(Array.from({ length: workerCount }, () => detectNext())) - return annotated -} - -async function refreshLocalBaseRefForWorktreeCreate( - repoPath: string, - baseBranch: string, - remoteTrackingRef: string, - remoteTrackingBase?: AddWorktreeOptions['remoteTrackingBase'], - options: GitWorktreeExecOptions = {} -): Promise { - const evaluation = await evaluateLocalBaseRefRefreshability( - repoPath, - baseBranch, - remoteTrackingRef, - remoteTrackingBase, - options - ) - if (!evaluation) { - return undefined - } - if (!evaluation.refreshable) { - return evaluation.result - } - - const resultBase = { baseRef: evaluation.baseRef, localBranch: evaluation.localBranch } - try { - if (evaluation.ownerWorktreePath) { - const { stdout: worktreeListOutput } = await gitExecFileAsync( - ['worktree', 'list', '--porcelain'], - gitExecOptions(repoPath, options) - ) - const worktrees = parseWorktreeList( - translateWslOutputPaths(worktreeListOutput, repoPath, options) - ) - 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'], - gitExecOptions(currentOwner.path, options) - ) - if (status.trim()) { - return { - ...resultBase, - status: 'skipped_dirty_worktree', - ownerWorktreePath: currentOwner.path - } - } - await gitExecFileAsync( - ['reset', '--hard', evaluation.remoteOid], - gitExecOptions(currentOwner.path, options) - ) - return { ...resultBase, status: 'updated', ownerWorktreePath: currentOwner.path } - } - - // Why: no owner worktree — fast-forward the bare ref; the expected-old-OID form is a no-op-safe CAS if the ref moved since evaluation. - await gitExecFileAsync( - ['update-ref', evaluation.fullRef, evaluation.remoteOid, evaluation.localOid], - gitExecOptions(repoPath, options) - ) - return { ...resultBase, status: 'updated' } - } catch { - // update-ref/reset can fail on locked refs or odd worktree states; worktree creation should still proceed. - return { ...resultBase, status: 'skipped_error' } - } -} - -/** - * Create a new worktree. - * @param repoPath - Path to the main repo (or bare repo) - * @param worktreePath - Absolute path where the worktree will be created - * @param branch - Branch name for the new worktree - * @param baseBranch - Optional base branch to create from (defaults to HEAD) - * @remarks Side effects (best-effort, warn-only): passes `--no-track`, writes - * `branch..base` for new-branch worktrees with a base ref, and may - * write `push.autoSetupRemote=true` to the repo's shared config. - */ +/** Create a worktree and invalidate read caches and in-flight worktree scans. */ export async function addWorktree( repoPath: string, worktreePath: string, @@ -960,14 +45,15 @@ export async function addWorktree( ): Promise { try { return await runWithGitReadCacheInvalidation(() => - performAddWorktree( + createWorktreeCheckout( repoPath, worktreePath, branch, baseBranch, refreshLocalBaseRef, noCheckout, - options + options, + resolveWorktreeAddTimeoutMs ) ) } finally { @@ -975,102 +61,6 @@ export async function addWorktree( } } -async function performAddWorktree( - repoPath: string, - worktreePath: string, - branch: string, - baseBranch?: string, - refreshLocalBaseRef = false, - noCheckout = false, - options: AddWorktreeOptions = {} -): Promise { - let localBaseRefRefresh: LocalBaseRefRefreshResult | undefined - let localBaseRefUpdateSuggestion: LocalBaseRefUpdateSuggestion | undefined - // Why: enable long paths for this Windows checkout without changing user Git config. - const args = [...windowsLongPathGitArgs(repoPath), 'worktree', 'add'] - let effectiveBase: string | undefined - if (noCheckout) { - args.push('--no-checkout') - } - if (options.checkoutExistingBranch) { - // Why: -b would create a new branch instead of checking out the selected one. - args.push(worktreePath, branch) - } else { - // Why: --no-track avoids inheriting the base's upstream so `git status` won't misreport "behind by N" pre-publish; first push sets it (see push.autoSetupRemote below). - args.push('--no-track', '-b', branch, worktreePath) - if (baseBranch) { - effectiveBase = await resolveWorktreeAddBaseRef(baseBranch, (qualifiedRef) => - hasWorktreeBaseCommitRef(repoPath, qualifiedRef, options) - ) - // Why: resolve the creation base first to distinguish remote-tracking refs from slash-containing local branches (mutation gated behind the explicit setting). - if (refreshLocalBaseRef) { - localBaseRefRefresh = await refreshLocalBaseRefForWorktreeCreate( - repoPath, - baseBranch, - effectiveBase, - options.remoteTrackingBase, - options - ) - } else if (options.suggestLocalBaseRefUpdate) { - localBaseRefUpdateSuggestion = await getLocalBaseRefUpdateSuggestionForWorktreeCreate( - repoPath, - baseBranch, - effectiveBase, - options.remoteTrackingBase, - options - ) - } - args.push(effectiveBase) - } - } - await gitExecFileAsync(args, { - ...gitExecOptions(repoPath, options), - // Why: resolve per call — hoisting this to a module const would freeze the override at import. - timeout: resolveWorktreeAddTimeoutMs() - }) - - if (options.checkoutExistingBranch) { - return localBaseRefRefresh ? { localBaseRefRefresh } : {} - } - - if (effectiveBase) { - await persistWorktreeCreationBase(worktreePath, branch, effectiveBase, options) - } - - // SSH parity: relay's addWorktreeOp (src/relay/git-handler-worktree-ops.ts) mirrors this — change both in lockstep. - // Why: --no-track leaves no upstream until first push; push.autoSetupRemote=true lets a plain - // `git push` create+set origin/ (git >=2.37; older clients ignore it). `--local` on a - // linked worktree writes the shared common-dir config (whole repo) — intentional and idempotent, - // so it's warn-only and not rolled back on failure. - try { - // Why: `--get` (not `--local --get`) so a value at any scope counts as "user already chose" and isn't overwritten. - let alreadySet = false - try { - await gitExecFileAsync(['config', '--get', 'push.autoSetupRemote'], { - ...gitExecOptions(worktreePath, options) - }) - alreadySet = true - } catch (readError) { - // Why: `git config --get` exits 1 only when unset at every scope; any other code is a real read failure — rethrow rather than overwrite the user's value. - const code = (readError as { code?: unknown })?.code - if (code !== 1) { - throw readError - } - } - if (!alreadySet) { - await gitExecFileAsync(['config', '--local', 'push.autoSetupRemote', 'true'], { - ...gitExecOptions(worktreePath, options) - }) - } - } catch (error) { - console.warn(`addWorktree: failed to set push.autoSetupRemote for ${worktreePath}`, error) - } - return { - ...(localBaseRefRefresh ? { localBaseRefRefresh } : {}), - ...(localBaseRefUpdateSuggestion ? { localBaseRefUpdateSuggestion } : {}) - } -} - export async function addSparseWorktree( repoPath: string, worktreePath: string, @@ -1093,59 +83,43 @@ export async function addSparseWorktree( options ) created = true - // Why: `worktree add --no-checkout` writes no files, so these are the calls that - // actually materialize the deep path and need the long-path escape hatch. const longPathArgs = windowsLongPathGitArgs(worktreePath) await gitExecFileAsync( ['sparse-checkout', 'init', '--cone'], - gitExecOptions(worktreePath, options) + gitWorktreeExecOptions(worktreePath, options) ) await gitExecFileAsync( [...longPathArgs, 'sparse-checkout', 'set', '--', ...directories], - gitExecOptions(worktreePath, options) + gitWorktreeExecOptions(worktreePath, options) ) await gitExecFileAsync( [...longPathArgs, 'checkout', branch], - gitExecOptions(worktreePath, options) + gitWorktreeExecOptions(worktreePath, options) ) return addResult } catch (error) { - const wrapped: SparseWorktreeCreateError = - error instanceof Error ? (error as SparseWorktreeCreateError) : new Error(String(error)) + const wrapped = error instanceof Error ? error : new Error(String(error)) + const sparseError = wrapped as Error & { cleanupFailed?: boolean } if (created) { if (!options.checkoutExistingBranch) { - try { - await unsetWorktreeCreationBase(worktreePath, branch, options) - } catch (cleanupError) { - console.warn( - `addSparseWorktree: failed to clear creation base for ${worktreePath}`, - cleanupError - ) - } + await clearWorktreeCreationBase(worktreePath, branch, options) } try { await removeWorktree(repoPath, worktreePath, true, { deleteBranch: !options.checkoutExistingBranch, - // Why: failed-creation rollback — the fresh branch has no user commits, so force-delete rather than orphan it. forceBranchDelete: !options.checkoutExistingBranch, ...(options.wslDistro ? { wslDistro: options.wslDistro } : {}) }) } catch { - wrapped.cleanupFailed = true - // Why: surface that manual cleanup may be needed, else a half-created worktree lingers silently on disk. - wrapped.message = `${wrapped.message} (cleanup also failed — the partially created worktree at "${worktreePath}" may need manual removal)` + sparseError.cleanupFailed = true + sparseError.message = `${sparseError.message} (cleanup also failed — the partially created worktree at "${worktreePath}" may need manual removal)` } } - throw wrapped + throw sparseError } } -/** - * Move a worktree with `git worktree move` (not `fs.rename`, which corrupts the - * `.git` file and the `.git/worktrees//gitdir` back-pointer). Local-only, - * so there is no relay parity handler. Caller owns migrating Orca's - * path-derived worktree identity and pre-checks that the destination is free. - */ +/** Move via Git so linked-worktree administrative back-pointers stay valid. */ export async function moveWorktree( repoPath: string, oldPath: string, @@ -1160,14 +134,11 @@ export async function moveWorktree( } } -/** - * Remove a worktree. - */ +/** Remove a worktree and invalidate read caches and in-flight worktree scans. */ export async function removeWorktree( repoPath: string, worktreePath: string, force = false, - // forceBranchDelete: for failed-creation rollback (fresh branch, no user work); user deletes leave it false so unmerged commits survive. options: RemoveWorktreeOptions = {} ): Promise { try { @@ -1178,516 +149,3 @@ export async function removeWorktree( bumpWorktreeScanGeneration(repoPath) } } - -async function performRemoveWorktree( - repoPath: string, - worktreePath: string, - force = false, - options: RemoveWorktreeOptions = {} -): Promise { - const removedWorktree = - options.knownRemovedWorktree ?? - (await listWorktrees(repoPath, options)).find((worktree) => - areWorktreePathsEqual(worktree.path, worktreePath) - ) - const branchName = normalizeLocalBranchRef(removedWorktree?.branch ?? '') - const branchHead = removedWorktree?.head ?? '' - - // Why: callers outside the IPC/runtime preflight must not bypass Git's lock contract or rely on localized stderr after side effects. - assertWorktreeUnlockedForRemoval(removedWorktree) - - if ( - !(await tryRemoveWorktreeWithDeferredDirectoryDeletion(repoPath, worktreePath, force, options)) - ) { - const args = ['worktree', 'remove'] - if (force) { - args.push('--force') - } - args.push(worktreePath) - try { - await gitExecFileAsync(args, gitExecOptions(repoPath, options)) - } catch (error) { - if (force || !isSubmoduleWorktreeRemovalRefusal(error)) { - throw error - } - // Why: Git refuses non-force removal of a worktree with an initialised submodule even when clean; re-prove cleanliness, then --force. - await assertWorktreeCleanForRemoval(worktreePath, false, options) - await gitExecFileAsync( - ['worktree', 'remove', '--force', worktreePath], - gitExecOptions(repoPath, options) - ) - } - } - - if (!branchName) { - return {} - } - if (options.deleteBranch === false) { - return {} - } - - // Why its own span: branch cleanup can reach the network (`fetch --prune`), so a stall here reads as - // `git worktree remove` being slow unless it is timed separately. - return withSpan('worktree.remove.branch_delete', () => - deleteBranchAfterWorktreeRemoval(repoPath, branchName, branchHead, options) - ) -} - -/** - * Rename the checkout into a sibling trash directory and clear Git's registration for the - * now-missing path, so the multi-GB recursive delete runs after this removal has returned. - * Returns false when the caller must let `git worktree remove` delete the directory inline. - */ -async function tryRemoveWorktreeWithDeferredDirectoryDeletion( - repoPath: string, - worktreePath: string, - force: boolean, - options: RemoveWorktreeOptions -): Promise { - // Why: WSL-owned checkouts are deleted inside the distro, so Node on Windows must not rename them. - if (options.wslDistro || parseWslPath(worktreePath)) { - return false - } - if (!force) { - try { - // Why: `git worktree remove` re-checks cleanliness as it removes; prove the same thing here or leave removal to Git. - await assertWorktreeCleanForRemoval(worktreePath, false, options) - } catch { - return false - } - } - - const trashPath = await withWorktreeRemoveStageSpan('trash_rename', 'local', () => - moveWorktreeDirectoryToTrash(worktreePath) - ) - if (!trashPath) { - return false - } - try { - await clearGitRegistrationForMissingWorktree(repoPath, worktreePath, options) - } catch (error) { - // Why: put the checkout back so the in-place removal below still sees the worktree Git registered. - if (await restoreWorktreeDirectoryFromTrash(trashPath, worktreePath)) { - return false - } - throw error - } - scheduleWorktreeTrashDeletion(trashPath) - return true -} - -async function clearGitRegistrationForMissingWorktree( - repoPath: string, - worktreePath: string, - options: RemoveWorktreeOptions -): Promise { - const registrationOptions = { - ...options, - timeout: options.timeout ?? WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS - } - try { - // Removing an already-missing directory is accepted back to the Git 2.25 baseline and touches only this entry. - await gitExecFileAsync( - ['worktree', 'remove', '--force', worktreePath], - gitExecOptions(repoPath, registrationOptions) - ) - return - } catch (error) { - console.warn( - `[git] Failed to deregister the moved worktree "${worktreePath}"; pruning instead`, - error - ) - } - - await gitExecFileAsync(['worktree', 'prune'], gitExecOptions(repoPath, registrationOptions)) - // Strict (not the shared scan): an unreadable repo must not read as proof that the row is gone. - const stillRegistered = (await listWorktreesStrict(repoPath, registrationOptions)).some( - (worktree) => areWorktreePathsEqual(worktree.path, worktreePath) - ) - if (stillRegistered) { - throw new Error(`Git still reports a registration for "${worktreePath}" after pruning it.`) - } -} - -async function deleteBranchAfterWorktreeRemoval( - repoPath: string, - branchName: string, - branchHead: string, - options: RemoveWorktreeOptions -): Promise { - try { - // Why: also drop the now-orphaned branch so delete-worktree leaves none; `-d` (not `-D`) preserves - // unmerged work, and forceBranchDelete opts into `-D` for failed-creation rollback. - const branchDeleteResult = await deleteLocalBranchAfterWorktreeRemoval( - repoPath, - branchName, - options.forceBranchDelete === true, - options - ) - if (branchDeleteResult === 'checked-out') { - return {} - } - return {} - } catch (error) { - if (!options.forceBranchDelete && branchHead) { - try { - if ( - await deleteAlreadyMergedBranchAfterSafeDeleteFailure( - repoPath, - branchName, - branchHead, - options - ) - ) { - return {} - } - } catch (alreadyMergedDeleteError) { - // Why: worktree is already gone; a raced branch cleanup should degrade to preserved-branch recovery, not fail delete. - console.warn( - `[git] Failed to delete already-merged local branch "${branchName}" after removing worktree`, - alreadyMergedDeleteError - ) - } - } - // Keep an unmerged/unpublished branch: deleting a worktree must never silently discard commits. - console.warn( - `[git] Preserved local branch "${branchName}" after removing worktree (not fully merged)`, - error - ) - return { preservedBranch: { branchName, ...(branchHead ? { head: branchHead } : {}) } } - } -} - -async function deleteLocalBranchAfterWorktreeRemoval( - repoPath: string, - branchName: string, - forceBranchDelete: boolean, - options: GitWorktreeExecOptions = {} -): Promise<'deleted' | 'checked-out'> { - const deleteFlag = forceBranchDelete ? '-D' : '-d' - try { - await gitExecFileAsync( - ['branch', deleteFlag, '--', branchName], - gitExecOptions(repoPath, options) - ) - return 'deleted' - } catch (error) { - if (!isBranchCheckedOutInWorktreeError(error)) { - throw error - } - } - - try { - // Why: only pay for `worktree prune` when a stale admin record may be blocking `branch -d`. - 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], - gitExecOptions(repoPath, options) - ) - return 'deleted' - } catch (error) { - if (isBranchCheckedOutInWorktreeError(error)) { - return 'checked-out' - } - throw error - } -} - -async function deleteAlreadyMergedBranchAfterSafeDeleteFailure( - repoPath: string, - branchName: string, - branchHead: string, - options: GitWorktreeExecOptions = {} -): Promise { - const runGit = (args: string[], execOptions?: { stdin?: string }) => - gitExecFileAsync(args, { - ...gitExecOptions(repoPath, options), - ...(execOptions?.stdin !== undefined ? { stdin: execOptions.stdin } : {}) - }) - const targetRefs = await getBranchCleanupTargetRefs(runGit, branchName) - // Why: squash merges rewrite commit IDs, so `branch -d` rejects already-merged branches; delete only when Git proves no unmerged tree changes. - const hasNoUnmergedChanges = await withLocalGitCapabilityCacheForExecution( - { cwd: repoPath, wslDistro: options.wslDistro, signal: options.signal }, - (capabilities) => - branchHasNoUnmergedChangesWithLazyTargetRefresh(runGit, branchName, targetRefs, capabilities) - ) - if (!hasNoUnmergedChanges) { - return false - } - await forceDeleteLocalBranch(repoPath, branchName, branchHead, (args, cwd) => - gitExecFileAsync(args, gitExecOptions(cwd, options)) - ) - return true -} - -export async function forceDeleteLocalBranch( - repoPath: string, - branchName: string, - expectedHead: string, - runGit: (args: string[], cwd: string) => Promise<{ stdout: string; stderr: string }> = ( - args, - cwd - ) => gitExecFileAsync(args, { cwd }) -): Promise { - if (!branchName || branchName.includes('\0')) { - throw new Error('Invalid branch name') - } - if (!expectedHead) { - throw new Error( - `Cannot force-delete local branch "${branchName}" without the commit Git preserved.` - ) - } - if (await isLocalBranchCheckedOut(repoPath, branchName, runGit)) { - throw new Error(`Local branch "${branchName}" is checked out in another worktree.`) - } - // Why: stale toast actions must not delete a branch that moved; `update-ref -d` deletes only if the ref still == expectedHead. - try { - await runGit(['update-ref', '-d', `refs/heads/${branchName}`, expectedHead], repoPath) - } catch { - throw new Error( - `Local branch "${branchName}" changed after the workspace was deleted. Review it before deleting it.` - ) - } - if (await isLocalBranchCheckedOut(repoPath, branchName, runGit)) { - try { - await runGit(['update-ref', `refs/heads/${branchName}`, expectedHead, ''], repoPath) - } catch (restoreError) { - console.warn( - `[git] Failed to restore local branch "${branchName}" after concurrent checkout`, - restoreError - ) - } - throw new Error(`Local branch "${branchName}" is checked out in another worktree.`) - } - try { - await runGit(['config', '--remove-section', `branch.${branchName}`], repoPath) - } catch { - // Best-effort parity with `git branch -D`; stale config is harmless. - } -} - -async function isLocalBranchCheckedOut( - repoPath: string, - branchName: string, - runGit: (args: string[], cwd: string) => Promise<{ stdout: string; stderr: string }> -): Promise { - const { stdout } = await runGit(['worktree', 'list', '--porcelain'], repoPath) - return parseWorktreeList(stdout).some( - (worktree) => normalizeLocalBranchRef(worktree.branch) === branchName - ) -} - -/** - * Assert a worktree is clean enough for non-force removal. - */ -export async function assertWorktreeCleanForRemoval( - worktreePath: string, - force = false, - options: WorktreeRemovalPreflightOptions = {} -): Promise { - if (force) { - return - } - - const { ignoredUntrackedPaths = [], ...gitOptions } = options - const useNullTerminatedStatus = ignoredUntrackedPaths.length > 0 - const { stdout } = await gitExecFileAsync( - ['status', '--porcelain', ...(useNullTerminatedStatus ? ['-z'] : []), '--untracked-files=all'], - { - ...gitExecOptions(worktreePath, gitOptions), - timeout: gitOptions.timeout ?? WORKTREE_REMOVAL_PREFLIGHT_TIMEOUT_MS - } - ) - // Why one parse feeds both: the clean verdict and the error text must never - // disagree about which entries block removal. - const blockingEntries = useNullTerminatedStatus - ? getBlockingUntrackedStatusEntries(stdout, ignoredUntrackedPaths) - : null - if (blockingEntries ? blockingEntries.length === 0 : !stdout.trim()) { - return - } - - const error = new Error('Worktree has uncommitted or untracked changes.') - // Why not the raw stdout: `-z` output is NUL-delimited and `.trim()` leaves - // interior NULs, so attaching it verbatim put raw control bytes into the - // user-facing removal error — and listed the tolerated shared link, the one - // entry that is not the user's work and cannot be committed away. - ;(error as Error & { stdout?: string }).stdout = blockingEntries - ? blockingEntries.join('\n') - : stdout - throw error -} - -/** The `git status --porcelain -z` entries that genuinely block removal: - * everything except the untracked shared links the caller tolerates. */ -function getBlockingUntrackedStatusEntries( - status: string, - ignoredUntrackedPaths: readonly string[] -): string[] { - const ignored = new Set( - ignoredUntrackedPaths - .map((entry) => - entry - .trim() - .replace(/^[\\/]+/, '') - .replace(/\\/g, '/') - ) - .filter((entry) => entry && !entry.split('/').includes('..')) - ) - return status - .split('\0') - .filter(Boolean) - .filter( - (entry) => !(entry.startsWith('?? ') && ignored.has(entry.slice(3).replace(/\\/g, '/'))) - ) -} - -function translateWorktreePath( - worktreePath: string, - repoPath: string, - options: GitWorktreeExecOptions = {} -): string { - const prefix = 'worktree ' - const translated = translateWslOutputPaths(`${prefix}${worktreePath}`, repoPath, options) - return translated.startsWith(prefix) ? translated.slice(prefix.length) : worktreePath -} - -async function detectSparseCheckout(worktreePath: string): Promise { - // Why: fs.stat the per-worktree gitdir's sparse-checkout pattern file instead of a per-poll `git sparse-checkout list` subprocess that regressed responsiveness (PR #1290); - // this is the cheap fast-path gate before the enabled check below. - try { - const gitDir = await resolveGitDir(worktreePath) - const stats = await stat(join(gitDir, 'info', 'sparse-checkout')) - if (!stats.isFile() || stats.size === 0) { - return false - } - // Why the extra config read: `git sparse-checkout disable` restores every file to the - // working tree and sets core.sparseCheckout=false, but it deliberately LEAVES - // /info/sparse-checkout in place so the checkout can be re-enabled with the same - // patterns. A non-empty pattern file is therefore necessary but not sufficient — without - // confirming core.sparseCheckout is actually on we would flag a fully-populated worktree as - // sparse and show a misleading "files are not on disk" badge. This runs only for the rare - // worktree that still has a non-empty pattern file, so it does not reintroduce the per-poll - // subprocess fan-out PR #1290 removed, and it reads git's config files directly (no - // subprocess) so it stays cheap and needs no exec options. - return await isSparseCheckoutEnabled(gitDir) - } catch { - return false - } -} - -// Resolve the shared common gitdir for a (possibly linked) worktree gitdir. A linked worktree's -// gitdir holds a `commondir` file pointing at the repo's main `.git`; the main worktree's gitdir -// is itself the common dir. -async function resolveGitCommonDir(gitDir: string): Promise { - try { - const raw = (await readFile(join(gitDir, 'commondir'), 'utf-8')).trim() - if (raw.length > 0) { - return isAbsolute(raw) ? raw : resolve(gitDir, raw) - } - } catch { - // No `commondir` file: this gitdir is already the common dir. - } - return gitDir -} - -// Whether core.sparseCheckout is actually enabled for this worktree. The value can live in the -// shared repo config or, when extensions.worktreeConfig is on, in the worktree-local -// `config.worktree`; later files override earlier ones, matching git's config precedence. -async function isSparseCheckoutEnabled(gitDir: string): Promise { - const commonDir = await resolveGitCommonDir(gitDir) - const sharedConfig = await readGitConfigText(join(commonDir, 'config')) - const sharedFlag = parseCoreSparseCheckoutFlag(sharedConfig) - // Git reads `config.worktree` only while extensions.worktreeConfig is on; without that gate a - // stale worktree config left behind by an earlier sparse checkout overrides the real repo value. - if (parseGitConfigFlag(sharedConfig, 'extensions', 'worktreeconfig') !== true) { - return sharedFlag ?? false - } - const worktreeConfig = await readGitConfigText(join(gitDir, 'config.worktree')) - return parseCoreSparseCheckoutFlag(worktreeConfig) ?? sharedFlag ?? false -} - -async function readGitConfigText(configPath: string): Promise { - try { - return await readFile(configPath, 'utf-8') - } catch { - return '' - } -} - -// Read the effective `core.sparseCheckout` boolean from one git config file's text, or `undefined` -// when the plain `[core]` section does not set it. Kept as a pure, exported function so the -// git-config parsing edge cases can be unit tested without touching the filesystem. Only the last -// assignment wins, and a `[core "subsection"]` header is intentionally not treated as `[core]`. -export function parseCoreSparseCheckoutFlag(configContent: string): boolean | undefined { - return parseGitConfigFlag(configContent, 'core', 'sparsecheckout') -} - -// A section header may be followed on the same line by further headers and then one assignment -// (`[core] sparseCheckout = true` is legal git config); the value runs to end of line, so at most -// one assignment can share a line and the last header before it decides the section. -const GIT_CONFIG_SECTION_HEADER = /^\[\s*([A-Za-z0-9.-]+)(\s+"(?:[^"\\]|\\.)*")?\s*\]/ -const GIT_CONFIG_ASSIGNMENT = /^([A-Za-z][A-Za-z0-9-]*)\s*(?:=\s*(.*))?$/ - -// `section` and `key` must be lowercase: git config names are case-insensitive. -function parseGitConfigFlag( - configContent: string, - section: string, - key: string -): boolean | undefined { - let inSection = false - let value: boolean | undefined - for (const rawLine of configContent.split(/\r?\n/)) { - let rest = stripGitConfigComment(rawLine).trim() - for ( - let header = rest.match(GIT_CONFIG_SECTION_HEADER); - header; - header = rest.match(GIT_CONFIG_SECTION_HEADER) - ) { - inSection = header[1].toLowerCase() === section && header[2] === undefined - rest = rest.slice(header[0].length).trim() - } - if (!inSection || rest.length === 0) { - continue - } - const assignment = rest.match(GIT_CONFIG_ASSIGNMENT) - if (!assignment || assignment[1].toLowerCase() !== key) { - continue - } - value = parseGitConfigBoolean(assignment[2]) - } - return value -} - -// Drop a trailing `#`/`;` comment that is not inside a double-quoted value. -function stripGitConfigComment(line: string): string { - let inQuotes = false - for (let index = 0; index < line.length; index += 1) { - const char = line[index] - if (char === '"' && line[index - 1] !== '\\') { - inQuotes = !inQuotes - } else if ((char === '#' || char === ';') && !inQuotes) { - return line.slice(0, index) - } - } - return line -} - -// Git treats a valueless boolean (`sparseCheckout` with no `=`) as true and only true/yes/on/1 as -// true otherwise; everything else (including the disable-written `false`) is false. -function parseGitConfigBoolean(raw: string | undefined): boolean { - if (raw === undefined) { - return true - } - const value = raw - .trim() - .replace(/^"(.*)"$/, '$1') - .toLowerCase() - return value === 'true' || value === 'yes' || value === 'on' || value === '1' -} diff --git a/src/main/github/auth-diagnose.test.ts b/src/main/github/auth-diagnose.test.ts index 7735910dc83..5291550b878 100644 --- a/src/main/github/auth-diagnose.test.ts +++ b/src/main/github/auth-diagnose.test.ts @@ -2,9 +2,7 @@ import { describe, expect, it, vi } from 'vitest' const { ghExecFileAsyncMock } = vi.hoisted(() => ({ ghExecFileAsyncMock: vi.fn() })) -vi.mock('../git/runner', () => ({ - ghExecFileAsync: ghExecFileAsyncMock -})) +vi.mock('../git/github-cli-runner', () => ({ ghExecFileAsync: ghExecFileAsyncMock })) import { diagnoseGhAuth, parseAuthStatus } from './auth-diagnose' describe('parseAuthStatus', () => { diff --git a/src/main/github/auth-diagnose.ts b/src/main/github/auth-diagnose.ts index 61aafdd7cf8..f22f4099a12 100644 --- a/src/main/github/auth-diagnose.ts +++ b/src/main/github/auth-diagnose.ts @@ -11,7 +11,7 @@ * Output is parsed from `gh auth status`, which prints free-form text but * uses stable field labels ("Token scopes:", "(GITHUB_TOKEN)", etc.). */ -import { ghExecFileAsync } from '../git/runner' +import { ghExecFileAsync } from '../git/github-cli-runner' import type { GhAuthDiagnostic, GhAuthAccount } from '../../shared/github/auth-types' // Required scopes for ProjectV2 GraphQL access in Orca. `project` is the diff --git a/src/main/github/client-create-pr.test.ts b/src/main/github/client-create-pr.test.ts index c3efb986bad..6efd5232340 100644 --- a/src/main/github/client-create-pr.test.ts +++ b/src/main/github/client-create-pr.test.ts @@ -1,3 +1,4 @@ +vi.mock('../git/github-cli-runner', () => ({ ghExecFileAsync: ghExecFileAsyncMock })) import { beforeEach, describe, expect, it, vi } from 'vitest' import { readFile } from 'node:fs/promises' diff --git a/src/main/github/client-file-viewed.test.ts b/src/main/github/client-file-viewed.test.ts index a52a479e9f5..c723823f19c 100644 --- a/src/main/github/client-file-viewed.test.ts +++ b/src/main/github/client-file-viewed.test.ts @@ -1,3 +1,4 @@ +vi.mock('../git/github-cli-runner', () => ({ ghExecFileAsync: ghExecFileAsyncMock })) import { beforeEach, describe, expect, it, vi } from 'vitest' const { diff --git a/src/main/github/client-issue-origin-preference.test.ts b/src/main/github/client-issue-origin-preference.test.ts index 951962a3c25..12c8ede3f36 100644 --- a/src/main/github/client-issue-origin-preference.test.ts +++ b/src/main/github/client-issue-origin-preference.test.ts @@ -1,3 +1,4 @@ +vi.mock('../git/github-cli-runner', () => ({ ghExecFileAsync: ghExecFileAsyncMock })) import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as GithubApiRepositoryModule from './github-api-repository' import type * as GhUtils from './gh-utils' diff --git a/src/main/github/client-issue-source.test.ts b/src/main/github/client-issue-source.test.ts index 17820ddd0f1..5a68a2dc638 100644 --- a/src/main/github/client-issue-source.test.ts +++ b/src/main/github/client-issue-source.test.ts @@ -1,3 +1,4 @@ +vi.mock('../git/github-cli-runner', () => ({ ghExecFileAsync: ghExecFileAsyncMock })) import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as GithubApiRepositoryModule from './github-api-repository' import type * as GhUtils from './gh-utils' diff --git a/src/main/github/client-merge-queue-auto-merge.test.ts b/src/main/github/client-merge-queue-auto-merge.test.ts index d099408d022..3346365d712 100644 --- a/src/main/github/client-merge-queue-auto-merge.test.ts +++ b/src/main/github/client-merge-queue-auto-merge.test.ts @@ -1,3 +1,4 @@ +vi.mock('../git/github-cli-runner', () => ({ ghExecFileAsync: clientMocks.ghExecFileAsyncMock })) import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as GithubApiRepositoryModule from './github-api-repository' import type * as GitHubEnterpriseRepositoryModule from './github-enterprise-repository' diff --git a/src/main/github/client-merged-pr-visibility.test.ts b/src/main/github/client-merged-pr-visibility.test.ts index 22501ec9735..03df2ea0f59 100644 --- a/src/main/github/client-merged-pr-visibility.test.ts +++ b/src/main/github/client-merged-pr-visibility.test.ts @@ -1,3 +1,4 @@ +vi.mock('../git/github-cli-runner', () => ({ ghExecFileAsync: clientMocks.ghExecFileAsyncMock })) import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as GithubApiRepositoryModule from './github-api-repository' import type * as GitHubEnterpriseRepositoryModule from './github-enterprise-repository' diff --git a/src/main/github/client-pr-branch-discovery.test.ts b/src/main/github/client-pr-branch-discovery.test.ts index 2e89ecfa45e..e33f88903d0 100644 --- a/src/main/github/client-pr-branch-discovery.test.ts +++ b/src/main/github/client-pr-branch-discovery.test.ts @@ -1,3 +1,4 @@ +vi.mock('../git/github-cli-runner', () => ({ ghExecFileAsync: clientMocks.ghExecFileAsyncMock })) import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as GithubApiRepositoryModule from './github-api-repository' import type * as GitHubEnterpriseRepositoryModule from './github-enterprise-repository' diff --git a/src/main/github/client-pr-check-details.test.ts b/src/main/github/client-pr-check-details.test.ts index 4bb2fcbae76..91271f83936 100644 --- a/src/main/github/client-pr-check-details.test.ts +++ b/src/main/github/client-pr-check-details.test.ts @@ -1,3 +1,4 @@ +vi.mock('../git/github-cli-runner', () => ({ ghExecFileAsync: ghExecFileAsyncMock })) import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as GithubEnterpriseRepositoryModule from './github-enterprise-repository' diff --git a/src/main/github/client-pr-checks.test.ts b/src/main/github/client-pr-checks.test.ts index 43be8ba642e..e2abc696870 100644 --- a/src/main/github/client-pr-checks.test.ts +++ b/src/main/github/client-pr-checks.test.ts @@ -1,3 +1,4 @@ +vi.mock('../git/github-cli-runner', () => ({ ghExecFileAsync: ghExecFileAsyncMock })) import { beforeEach, describe, expect, it, vi } from 'vitest' const { diff --git a/src/main/github/client-pr-comment-reactions.test.ts b/src/main/github/client-pr-comment-reactions.test.ts index f2792c7922a..5ea9130b819 100644 --- a/src/main/github/client-pr-comment-reactions.test.ts +++ b/src/main/github/client-pr-comment-reactions.test.ts @@ -1,3 +1,4 @@ +vi.mock('../git/github-cli-runner', () => ({ ghExecFileAsync: clientMocks.ghExecFileAsyncMock })) import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as GithubApiRepositoryModule from './github-api-repository' import type * as GitHubEnterpriseRepositoryModule from './github-enterprise-repository' diff --git a/src/main/github/client-pr-conflict-summary.test.ts b/src/main/github/client-pr-conflict-summary.test.ts index a7193911274..9c208734bea 100644 --- a/src/main/github/client-pr-conflict-summary.test.ts +++ b/src/main/github/client-pr-conflict-summary.test.ts @@ -1,3 +1,4 @@ +vi.mock('../git/github-cli-runner', () => ({ ghExecFileAsync: clientMocks.ghExecFileAsyncMock })) import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as GithubApiRepositoryModule from './github-api-repository' import type * as GitHubEnterpriseRepositoryModule from './github-enterprise-repository' diff --git a/src/main/github/client-pr-fallback-number.test.ts b/src/main/github/client-pr-fallback-number.test.ts index 59245e37756..631380875e4 100644 --- a/src/main/github/client-pr-fallback-number.test.ts +++ b/src/main/github/client-pr-fallback-number.test.ts @@ -1,3 +1,4 @@ +vi.mock('../git/github-cli-runner', () => ({ ghExecFileAsync: clientMocks.ghExecFileAsyncMock })) import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as GithubApiRepositoryModule from './github-api-repository' import type * as GitHubEnterpriseRepositoryModule from './github-enterprise-repository' diff --git a/src/main/github/client-pr-linked-lookup.test.ts b/src/main/github/client-pr-linked-lookup.test.ts index e4965ee0cb9..46dce728051 100644 --- a/src/main/github/client-pr-linked-lookup.test.ts +++ b/src/main/github/client-pr-linked-lookup.test.ts @@ -1,3 +1,4 @@ +vi.mock('../git/github-cli-runner', () => ({ ghExecFileAsync: clientMocks.ghExecFileAsyncMock })) import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as GithubApiRepositoryModule from './github-api-repository' import type * as GitHubEnterpriseRepositoryModule from './github-enterprise-repository' diff --git a/src/main/github/client-pr-local-runtime.test.ts b/src/main/github/client-pr-local-runtime.test.ts index 0633b137b1c..3eacee81ba7 100644 --- a/src/main/github/client-pr-local-runtime.test.ts +++ b/src/main/github/client-pr-local-runtime.test.ts @@ -1,3 +1,4 @@ +vi.mock('../git/github-cli-runner', () => ({ ghExecFileAsync: ghExecFileAsyncMock })) import { beforeEach, describe, expect, it, vi } from 'vitest' type RateLimitGuardResult = diff --git a/src/main/github/client-pr-push-target.test.ts b/src/main/github/client-pr-push-target.test.ts index daaa05155ae..f25e7f22cda 100644 --- a/src/main/github/client-pr-push-target.test.ts +++ b/src/main/github/client-pr-push-target.test.ts @@ -1,3 +1,4 @@ +vi.mock('../git/github-cli-runner', () => ({ ghExecFileAsync: clientMocks.ghExecFileAsyncMock })) import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as GithubApiRepositoryModule from './github-api-repository' import type * as GitHubEnterpriseRepositoryModule from './github-enterprise-repository' diff --git a/src/main/github/client-pr-state.test.ts b/src/main/github/client-pr-state.test.ts index 24391dc49ba..7e60e089f52 100644 --- a/src/main/github/client-pr-state.test.ts +++ b/src/main/github/client-pr-state.test.ts @@ -1,3 +1,4 @@ +vi.mock('../git/github-cli-runner', () => ({ ghExecFileAsync: clientMocks.ghExecFileAsyncMock })) import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as GithubApiRepositoryModule from './github-api-repository' import type * as GitHubEnterpriseRepositoryModule from './github-enterprise-repository' diff --git a/src/main/github/client-rate-limit-block.test.ts b/src/main/github/client-rate-limit-block.test.ts index a8c0a232ffa..a9d0de1807c 100644 --- a/src/main/github/client-rate-limit-block.test.ts +++ b/src/main/github/client-rate-limit-block.test.ts @@ -1,3 +1,4 @@ +vi.mock('../git/github-cli-runner', () => ({ ghExecFileAsync: clientMocks.ghExecFileAsyncMock })) import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as GithubApiRepositoryModule from './github-api-repository' import type * as GitHubEnterpriseRepositoryModule from './github-enterprise-repository' diff --git a/src/main/github/client-ssh-provider-execution-boundary.test.ts b/src/main/github/client-ssh-provider-execution-boundary.test.ts index 1f9bb1b1704..5812fba8631 100644 --- a/src/main/github/client-ssh-provider-execution-boundary.test.ts +++ b/src/main/github/client-ssh-provider-execution-boundary.test.ts @@ -1,3 +1,4 @@ +vi.mock('../git/github-cli-runner', () => ({ ghExecFileAsync: clientMocks.ghExecFileAsyncMock })) import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as GithubApiRepositoryModule from './github-api-repository' import type * as GitHubEnterpriseRepositoryModule from './github-enterprise-repository' diff --git a/src/main/github/client-stack-merge-guard.test.ts b/src/main/github/client-stack-merge-guard.test.ts index 57cd5df8bfd..1c4b6e29f88 100644 --- a/src/main/github/client-stack-merge-guard.test.ts +++ b/src/main/github/client-stack-merge-guard.test.ts @@ -9,6 +9,9 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => { vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks)) vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks)) +vi.mock('../git/github-cli-runner', () => ({ + ghExecFileAsync: clientMocks.ghExecFileAsyncMock +})) vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks)) vi.mock('./local-git-config-signature', () => moduleMocks.localGitConfigSignatureModuleMock(clientMocks) diff --git a/src/main/github/client-test-mocks.ts b/src/main/github/client-test-mocks.ts index 15fe0f36214..d0944f259c3 100644 --- a/src/main/github/client-test-mocks.ts +++ b/src/main/github/client-test-mocks.ts @@ -71,7 +71,6 @@ export function createGitHubClientMocks(): GitHubClientMocks { export type GhUtilsModuleMock = { execFileAsync: Mock - ghExecFileAsync: Mock getOwnerRepo: Mock getIssueOwnerRepo: Mock getOwnerRepoForRemote: Mock @@ -90,7 +89,6 @@ export type GhUtilsModuleMock = { export function ghUtilsModuleMock(mocks: GitHubClientMocks): GhUtilsModuleMock { return { execFileAsync: mocks.execFileAsyncMock, - ghExecFileAsync: mocks.ghExecFileAsyncMock, getOwnerRepo: mocks.getOwnerRepoMock, getIssueOwnerRepo: mocks.getIssueOwnerRepoMock, getOwnerRepoForRemote: mocks.getOwnerRepoForRemoteMock, @@ -122,12 +120,11 @@ export function ghUtilsModuleMock(mocks: GitHubClientMocks): GhUtilsModuleMock { } } -export type GitRunnerModuleMock = { gitExecFileAsync: Mock; ghExecFileAsync: Mock } +export type GitRunnerModuleMock = { gitExecFileAsync: Mock } export function gitRunnerModuleMock(mocks: GitHubClientMocks): GitRunnerModuleMock { return { - gitExecFileAsync: mocks.gitExecFileAsyncMock, - ghExecFileAsync: mocks.ghExecFileAsyncMock + gitExecFileAsync: mocks.gitExecFileAsyncMock } } diff --git a/src/main/github/client-tracked-upstream-fork-owner.test.ts b/src/main/github/client-tracked-upstream-fork-owner.test.ts index eb123739435..7ffd2bdd112 100644 --- a/src/main/github/client-tracked-upstream-fork-owner.test.ts +++ b/src/main/github/client-tracked-upstream-fork-owner.test.ts @@ -1,3 +1,4 @@ +vi.mock('../git/github-cli-runner', () => ({ ghExecFileAsync: clientMocks.ghExecFileAsyncMock })) import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as GithubApiRepositoryModule from './github-api-repository' import type * as GitHubEnterpriseRepositoryModule from './github-enterprise-repository' diff --git a/src/main/github/client-tracked-upstream-snapshot.test.ts b/src/main/github/client-tracked-upstream-snapshot.test.ts index 6bcf5dd9383..679227a284a 100644 --- a/src/main/github/client-tracked-upstream-snapshot.test.ts +++ b/src/main/github/client-tracked-upstream-snapshot.test.ts @@ -1,3 +1,4 @@ +vi.mock('../git/github-cli-runner', () => ({ ghExecFileAsync: clientMocks.ghExecFileAsyncMock })) import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as GithubApiRepositoryModule from './github-api-repository' import type * as GitHubEnterpriseRepositoryModule from './github-enterprise-repository' diff --git a/src/main/github/client-work-item-check-summary.test.ts b/src/main/github/client-work-item-check-summary.test.ts index 2543fa3a288..9481a4366b6 100644 --- a/src/main/github/client-work-item-check-summary.test.ts +++ b/src/main/github/client-work-item-check-summary.test.ts @@ -1,3 +1,4 @@ +vi.mock('../git/github-cli-runner', () => ({ ghExecFileAsync: ghExecFileAsyncMock })) import { beforeEach, describe, expect, it, vi } from 'vitest' const { ghExecFileAsyncMock, getOwnerRepoMock, rateLimitGuardMock } = vi.hoisted(() => ({ diff --git a/src/main/github/client-work-items-query-paging.test.ts b/src/main/github/client-work-items-query-paging.test.ts index 0e8b62bdfcf..c516d8e78d3 100644 --- a/src/main/github/client-work-items-query-paging.test.ts +++ b/src/main/github/client-work-items-query-paging.test.ts @@ -1,3 +1,4 @@ +vi.mock('../git/github-cli-runner', () => ({ ghExecFileAsync: ghExecFileAsyncMock })) import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as GithubApiRepositoryModule from './github-api-repository' diff --git a/src/main/github/client-work-items.test.ts b/src/main/github/client-work-items.test.ts index f26ccfc12d9..883332ed030 100644 --- a/src/main/github/client-work-items.test.ts +++ b/src/main/github/client-work-items.test.ts @@ -1,3 +1,4 @@ +vi.mock('../git/github-cli-runner', () => ({ ghExecFileAsync: ghExecFileAsyncMock })) import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as GithubApiRepositoryModule from './github-api-repository' diff --git a/src/main/github/client/check/check-job-log-tails.ts b/src/main/github/client/check/check-job-log-tails.ts index d6ee40f62d3..d1c6da8ba8a 100644 --- a/src/main/github/client/check/check-job-log-tails.ts +++ b/src/main/github/client/check/check-job-log-tails.ts @@ -1,6 +1,6 @@ import type { PRCheckRunDetails } from '../../../../shared/github/check-types' import { sliceCheckLogTail } from '../../../../shared/check-job-log-tail-slice' -import { ghExecFileAsync } from '../../gh-utils' +import { ghExecFileAsync } from '../../../git/github-cli-runner' import type { GitHubApiRepository } from '../../github-api-repository' import { githubRepoIdentityKey } from '../../../../shared/github/repository-identity-key' import type { GhExecOptions } from './../github-exec-scope' diff --git a/src/main/github/client/check/get-pr-check-details.ts b/src/main/github/client/check/get-pr-check-details.ts index 8a5c2c2487d..fdaa9516762 100644 --- a/src/main/github/client/check/get-pr-check-details.ts +++ b/src/main/github/client/check/get-pr-check-details.ts @@ -1,5 +1,6 @@ +import { ghExecFileAsync } from '../../../git/github-cli-runner' import type { PRCheckRunDetails } from '../../../../shared/github/check-types' -import { ghExecFileAsync, acquire, release, type LocalGitExecOptions } from '../../gh-utils' +import { acquire, release, type LocalGitExecOptions } from '../../gh-utils' import { resolveGitHubRepoExecution, type GitHubApiRepository } from '../../github-api-repository' import { GITHUB_CHECK_DETAILS_HOST_TIMEOUT_MS, diff --git a/src/main/github/client/check/get-pr-checks.ts b/src/main/github/client/check/get-pr-checks.ts index 50db1ee9926..04501182229 100644 --- a/src/main/github/client/check/get-pr-checks.ts +++ b/src/main/github/client/check/get-pr-checks.ts @@ -1,6 +1,7 @@ +import { ghExecFileAsync } from '../../../git/github-cli-runner' import type { PRCheckDetail } from '../../../../shared/github/check-types' import { GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE } from '../../../../shared/work-items' -import { ghExecFileAsync, acquire, release, type LocalGitExecOptions } from '../../gh-utils' +import { acquire, release, type LocalGitExecOptions } from '../../gh-utils' import { extractExecError } from '../../../git/exec-error' import { resolveGitHubRepoExecution, type GitHubApiRepository } from '../../github-api-repository' import { mapCheckStatus, mapCheckConclusion } from '../../mappers' diff --git a/src/main/github/client/check/rerun-pr-checks.ts b/src/main/github/client/check/rerun-pr-checks.ts index 82059bab664..e5f2d0552ca 100644 --- a/src/main/github/client/check/rerun-pr-checks.ts +++ b/src/main/github/client/check/rerun-pr-checks.ts @@ -1,5 +1,6 @@ +import { ghExecFileAsync } from '../../../git/github-cli-runner' import type { GitHubRerunPRChecksResult } from '../../../../shared/github/check-types' -import { ghExecFileAsync, acquire, release, type LocalGitExecOptions } from '../../gh-utils' +import { acquire, release, type LocalGitExecOptions } from '../../gh-utils' // Why: pure error helpers come from their own modules so tests that mock gh-utils still classify for real. import { extractExecError } from '../../../git/exec-error' import { classifyRerunChecksError } from '../../gh-error-classification' diff --git a/src/main/github/client/create/add-pr-review-comment.ts b/src/main/github/client/create/add-pr-review-comment.ts index b3c51440a6a..c5aec0d0fcb 100644 --- a/src/main/github/client/create/add-pr-review-comment.ts +++ b/src/main/github/client/create/add-pr-review-comment.ts @@ -1,14 +1,9 @@ +import { ghExecFileAsync } from '../../../git/github-cli-runner' import type { GitHubCommentResult, GitHubPRReviewCommentInput } from '../../../../shared/github/comment-types' -import { - ghExecFileAsync, - acquire, - release, - classifyGhError, - type LocalGitExecOptions -} from '../../gh-utils' +import { acquire, release, classifyGhError, type LocalGitExecOptions } from '../../gh-utils' import { resolveGitHubRepoExecution, type GitHubApiRepository } from '../../github-api-repository' import { mapReviewCommentResponse } from './../map/review-comment-response' export async function addPRReviewComment( diff --git a/src/main/github/client/create/create-github-pull-request.ts b/src/main/github/client/create/create-github-pull-request.ts index 68ea9fbe6e0..6f2cd4349c3 100644 --- a/src/main/github/client/create/create-github-pull-request.ts +++ b/src/main/github/client/create/create-github-pull-request.ts @@ -1,3 +1,4 @@ +import { ghExecFileAsync } from '../../../git/github-cli-runner' import type { CreateHostedReviewInput, CreateHostedReviewResult @@ -9,13 +10,7 @@ import { import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' -import { - ghExecFileAsync, - acquire, - release, - ghRepoExecOptions, - githubRepoContext -} from '../../gh-utils' +import { acquire, release, ghRepoExecOptions, githubRepoContext } from '../../gh-utils' import { getHostedReviewLocalGitOptions, type HostedReviewExecutionOptions diff --git a/src/main/github/client/create/pull-request-template.ts b/src/main/github/client/create/pull-request-template.ts index 7f53d429d90..233a87eef6c 100644 --- a/src/main/github/client/create/pull-request-template.ts +++ b/src/main/github/client/create/pull-request-template.ts @@ -1,8 +1,9 @@ +import { ghExecFileAsync } from '../../../git/github-cli-runner' import { readFile } from 'node:fs/promises' import { join } from 'node:path' import { getSshFilesystemProvider } from '../../../providers/ssh-filesystem-dispatch' import { joinWorktreeRelativePath } from '../../../runtime/runtime-relative-paths' -import { ghExecFileAsync, ghRepoExecOptions, githubRepoContext } from '../../gh-utils' +import { ghRepoExecOptions, githubRepoContext } from '../../gh-utils' import { getHostedReviewLocalGitOptions, type HostedReviewExecutionOptions diff --git a/src/main/github/client/detect/repository-merge-metadata.ts b/src/main/github/client/detect/repository-merge-metadata.ts index a7f8f8179d4..7172e2376bd 100644 --- a/src/main/github/client/detect/repository-merge-metadata.ts +++ b/src/main/github/client/detect/repository-merge-metadata.ts @@ -1,5 +1,5 @@ import { normalizeGitHubPRMergeMethodSettings } from '../../../../shared/github/pull-request-merge-methods' -import { ghExecFileAsync } from '../../gh-utils' +import { ghExecFileAsync } from '../../../git/github-cli-runner' import { githubHostExecOptions, type GitHubApiRepository } from '../../github-api-repository' import { githubRepoIdentityKey } from '../../../../shared/github/repository-identity-key' import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from '../../rate-limit' diff --git a/src/main/github/client/fetch/get-pr-comments.ts b/src/main/github/client/fetch/get-pr-comments.ts index 17ba9175a00..b1f20a6b000 100644 --- a/src/main/github/client/fetch/get-pr-comments.ts +++ b/src/main/github/client/fetch/get-pr-comments.ts @@ -1,6 +1,7 @@ +import { ghExecFileAsync } from '../../../git/github-cli-runner' import type { PRComment } from '../../../../shared/github/comment-types' import { GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE } from '../../../../shared/work-items' -import { ghExecFileAsync, acquire, release, type LocalGitExecOptions } from '../../gh-utils' +import { acquire, release, type LocalGitExecOptions } from '../../gh-utils' import { resolveGitHubRepoExecution, type GitHubApiRepository } from '../../github-api-repository' import { mapGraphQLReactionGroups, type GitHubGraphQLReactionGroup } from '../../comment-reactions' import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from '../../rate-limit' diff --git a/src/main/github/client/fetch/repo-slug-upstream.ts b/src/main/github/client/fetch/repo-slug-upstream.ts index 0e67b06b4e3..428223460b9 100644 --- a/src/main/github/client/fetch/repo-slug-upstream.ts +++ b/src/main/github/client/fetch/repo-slug-upstream.ts @@ -1,4 +1,5 @@ -import { ghExecFileAsync, acquire, release, type OwnerRepo } from '../../gh-utils' +import { ghExecFileAsync } from '../../../git/github-cli-runner' +import { acquire, release, type OwnerRepo } from '../../gh-utils' import { getHostedReviewLocalGitOptions, type HostedReviewExecutionOptions diff --git a/src/main/github/client/fetch/work-item-fetch.ts b/src/main/github/client/fetch/work-item-fetch.ts index aa4dd0794a8..a3bd04a2708 100644 --- a/src/main/github/client/fetch/work-item-fetch.ts +++ b/src/main/github/client/fetch/work-item-fetch.ts @@ -1,6 +1,6 @@ +import { ghExecFileAsync } from '../../../git/github-cli-runner' import type { IssueSourcePreference } from '../../../../shared/repo-types' import { - ghExecFileAsync, classifyGhError, ghRepoExecOptions, githubRepoContext, diff --git a/src/main/github/client/list/count-work-items.ts b/src/main/github/client/list/count-work-items.ts index e742597685e..c9a2e77910c 100644 --- a/src/main/github/client/list/count-work-items.ts +++ b/src/main/github/client/list/count-work-items.ts @@ -1,8 +1,8 @@ +import { ghExecFileAsync } from '../../../git/github-cli-runner' import type { IssueSourcePreference } from '../../../../shared/repo-types' import { isGitHubWorkItemsQueryTooLarge } from '../../../../shared/github/work-items-query-bounds' import { parseTaskQuery, type ParsedTaskQuery } from '../../../../shared/task-query' import { - ghExecFileAsync, acquire, release, ghRepoExecOptions, diff --git a/src/main/github/client/list/work-item-pages.ts b/src/main/github/client/list/work-item-pages.ts index 63d5e7a0098..3c86b78f64f 100644 --- a/src/main/github/client/list/work-item-pages.ts +++ b/src/main/github/client/list/work-item-pages.ts @@ -1,9 +1,9 @@ +import { ghExecFileAsync } from '../../../git/github-cli-runner' import type { ClassifiedError } from '../../../../shared/classified-error' import { classifyGitHubUnavailable } from '../../../../shared/github/api-availability' import { parseTaskQuery, type ParsedTaskQuery } from '../../../../shared/task-query' import { sortWorkItemsByNumber } from '../../../../shared/work-items' import { - ghExecFileAsync, classifyListIssuesError, classifyListPrsError, ghRepoExecOptions, diff --git a/src/main/github/client/lookup/pr-branch-lookup.ts b/src/main/github/client/lookup/pr-branch-lookup.ts index 2ee667ceacc..f36f71a6e28 100644 --- a/src/main/github/client/lookup/pr-branch-lookup.ts +++ b/src/main/github/client/lookup/pr-branch-lookup.ts @@ -1,4 +1,4 @@ -import { ghExecFileAsync } from '../../gh-utils' +import { ghExecFileAsync } from '../../../git/github-cli-runner' import type { OwnerRepo, ghRepoExecOptions } from '../../gh-utils' import { githubHostExecOptions, type GitHubApiRepository } from '../../github-api-repository' import type { GhExecOptions } from './../github-exec-scope' diff --git a/src/main/github/client/lookup/pr-number-lookup.ts b/src/main/github/client/lookup/pr-number-lookup.ts index 42a605eb295..b3d4e4e0ad1 100644 --- a/src/main/github/client/lookup/pr-number-lookup.ts +++ b/src/main/github/client/lookup/pr-number-lookup.ts @@ -1,4 +1,4 @@ -import { ghExecFileAsync } from '../../gh-utils' +import { ghExecFileAsync } from '../../../git/github-cli-runner' import type { OwnerRepo, ghRepoExecOptions } from '../../gh-utils' import { githubHostExecOptions, type GitHubApiRepository } from '../../github-api-repository' import { diff --git a/src/main/github/client/lookup/pull-request-push-target.ts b/src/main/github/client/lookup/pull-request-push-target.ts index 3dc19733c6c..6235160e94e 100644 --- a/src/main/github/client/lookup/pull-request-push-target.ts +++ b/src/main/github/client/lookup/pull-request-push-target.ts @@ -1,7 +1,7 @@ +import { ghExecFileAsync } from '../../../git/github-cli-runner' import type { IssueSourcePreference } from '../../../../shared/repo-types' import type { GitPushTarget } from '../../../../shared/worktree/types' import { - ghExecFileAsync, acquire, release, ghRepoExecOptions, diff --git a/src/main/github/client/merge/merge-pr.ts b/src/main/github/client/merge/merge-pr.ts index 07518d7993c..305b3c62292 100644 --- a/src/main/github/client/merge/merge-pr.ts +++ b/src/main/github/client/merge/merge-pr.ts @@ -1,6 +1,7 @@ +import { ghExecFileAsync } from '../../../git/github-cli-runner' import type { PRConflictSummary } from '../../../../shared/github/pull-request-types' import { getPRConflictSummary } from '../../conflict-summary' -import { ghExecFileAsync, acquire, release, type LocalGitExecOptions } from '../../gh-utils' +import { acquire, release, type LocalGitExecOptions } from '../../gh-utils' import { resolveGitHubRepoExecution, type GitHubApiRepository } from '../../github-api-repository' import { mergeGitHubPRStack } from '../../github-pr-stack' import { githubPRStackExecutionScope, type GhExecOptions } from './../github-exec-scope' diff --git a/src/main/github/client/merge/pr-auto-merge.ts b/src/main/github/client/merge/pr-auto-merge.ts index 0cc65c5fb03..35a0b61375c 100644 --- a/src/main/github/client/merge/pr-auto-merge.ts +++ b/src/main/github/client/merge/pr-auto-merge.ts @@ -1,11 +1,6 @@ +import { ghExecFileAsync } from '../../../git/github-cli-runner' import type { GitHubPRMergeMethod } from '../../../../shared/github/pull-request-types' -import { - ghExecFileAsync, - acquire, - release, - classifyGhError, - type LocalGitExecOptions -} from '../../gh-utils' +import { acquire, release, classifyGhError, type LocalGitExecOptions } from '../../gh-utils' import { resolveGitHubRepoExecution, type GitHubApiRepository } from '../../github-api-repository' import { githubPRStackExecutionScope, type GhExecOptions } from './../github-exec-scope' import { detectRepositoryMergeMetadata } from './../detect/repository-merge-metadata' diff --git a/src/main/github/client/update/pr-comment-reaction.ts b/src/main/github/client/update/pr-comment-reaction.ts index 872ba463eb9..cc2bacc47d7 100644 --- a/src/main/github/client/update/pr-comment-reaction.ts +++ b/src/main/github/client/update/pr-comment-reaction.ts @@ -1,5 +1,6 @@ +import { ghExecFileAsync } from '../../../git/github-cli-runner' import type { GitHubReactionContent } from '../../../../shared/github/comment-types' -import { ghExecFileAsync, acquire, release, type LocalGitExecOptions } from '../../gh-utils' +import { acquire, release, type LocalGitExecOptions } from '../../gh-utils' import { resolveGitHubRepoExecution, type GitHubApiRepository } from '../../github-api-repository' import { toGraphQLReactionContent } from '../../comment-reactions' import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from '../../rate-limit' diff --git a/src/main/github/client/update/pr-details.ts b/src/main/github/client/update/pr-details.ts index 2348174e931..5391379f393 100644 --- a/src/main/github/client/update/pr-details.ts +++ b/src/main/github/client/update/pr-details.ts @@ -1,5 +1,5 @@ +import { ghExecFileAsync } from '../../../git/github-cli-runner' import { - ghExecFileAsync, acquire, release, classifyPullRequestUpdateError, diff --git a/src/main/github/client/update/pr-file-viewed.ts b/src/main/github/client/update/pr-file-viewed.ts index 018de3c328b..99814740c35 100644 --- a/src/main/github/client/update/pr-file-viewed.ts +++ b/src/main/github/client/update/pr-file-viewed.ts @@ -1,4 +1,5 @@ -import { ghExecFileAsync, acquire, release, type LocalGitExecOptions } from '../../gh-utils' +import { ghExecFileAsync } from '../../../git/github-cli-runner' +import { acquire, release, type LocalGitExecOptions } from '../../gh-utils' import { resolveGitHubRepoExecution, type GitHubApiRepository } from '../../github-api-repository' import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from '../../rate-limit' /** diff --git a/src/main/github/client/update/pr-reviewers.ts b/src/main/github/client/update/pr-reviewers.ts index e502158f661..a31ba4db8b9 100644 --- a/src/main/github/client/update/pr-reviewers.ts +++ b/src/main/github/client/update/pr-reviewers.ts @@ -1,4 +1,5 @@ -import { ghExecFileAsync, acquire, release, type LocalGitExecOptions } from '../../gh-utils' +import { ghExecFileAsync } from '../../../git/github-cli-runner' +import { acquire, release, type LocalGitExecOptions } from '../../gh-utils' import { resolveGitHubRepoExecution, type GitHubApiRepository } from '../../github-api-repository' export async function requestPRReviewers( repoPath: string, diff --git a/src/main/github/client/update/pr-state.ts b/src/main/github/client/update/pr-state.ts index d22916c8d54..82717f8e526 100644 --- a/src/main/github/client/update/pr-state.ts +++ b/src/main/github/client/update/pr-state.ts @@ -1,6 +1,6 @@ +import { ghExecFileAsync } from '../../../git/github-cli-runner' import type { GitHubPullRequestStateUpdate } from '../../../../shared/issue-mutation-types' import { - ghExecFileAsync, acquire, release, classifyPullRequestUpdateError, diff --git a/src/main/github/client/update/resolve-review-thread.ts b/src/main/github/client/update/resolve-review-thread.ts index 2bad5d69b9c..98fc149e1cd 100644 --- a/src/main/github/client/update/resolve-review-thread.ts +++ b/src/main/github/client/update/resolve-review-thread.ts @@ -1,4 +1,5 @@ -import { ghExecFileAsync, acquire, release, type LocalGitExecOptions } from '../../gh-utils' +import { ghExecFileAsync } from '../../../git/github-cli-runner' +import { acquire, release, type LocalGitExecOptions } from '../../gh-utils' import { resolveGitHubRepoExecution, type GitHubApiRepository } from '../../github-api-repository' import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from '../../rate-limit' /** diff --git a/src/main/github/default-branch-stale-pr.test.ts b/src/main/github/default-branch-stale-pr.test.ts index fde4b6b8b84..b3c458551db 100644 --- a/src/main/github/default-branch-stale-pr.test.ts +++ b/src/main/github/default-branch-stale-pr.test.ts @@ -1,3 +1,4 @@ +vi.mock('../git/github-cli-runner', () => ({ ghExecFileAsync: ghExecFileAsyncMock })) /* * Issue #9171 — "Wrong PR diffs displayed when checking out the default branch". * diff --git a/src/main/github/gh-utils.test.ts b/src/main/github/gh-utils.test.ts index ba19fc13fda..08d86208172 100644 --- a/src/main/github/gh-utils.test.ts +++ b/src/main/github/gh-utils.test.ts @@ -15,6 +15,7 @@ vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock, ghExecFileAsync: vi.fn() })) +vi.mock('../git/github-cli-runner', () => ({ ghExecFileAsync: vi.fn() })) vi.mock('../providers/ssh-git-dispatch', () => ({ getSshGitProviderGeneration: getSshGitProviderGenerationMock, diff --git a/src/main/github/gh-utils.ts b/src/main/github/gh-utils.ts index 61b3c2d877d..8d78abdfed5 100644 --- a/src/main/github/gh-utils.ts +++ b/src/main/github/gh-utils.ts @@ -1,14 +1,14 @@ import { execFile } from 'node:child_process' import { promisify } from 'node:util' -import { gitExecFileAsync, ghExecFileAsync } from '../git/runner' -// Pure error-parsing helpers come from the lightweight module (not `runner`) so -// tests that mock `../git/runner` still resolve the real implementations. +import { gitExecFileAsync } from '../git/runner' +// Pure error parsing stays independent from either process runner so tests can +// mock Git and GitHub CLI execution without replacing classification behavior. import { extractExecError, parseRetryAfterMs } from '../git/exec-error' // 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. +// WSL-aware routing. Repo-scoped callers should use the WSL-aware exports below. export const execFileAsync = promisify(execFile) -export { ghExecFileAsync, gitExecFileAsync, extractExecError, parseRetryAfterMs } +export { gitExecFileAsync, extractExecError, parseRetryAfterMs } export { classifyGhError, classifyListIssuesError, diff --git a/src/main/github/github-enterprise-repository.test.ts b/src/main/github/github-enterprise-repository.test.ts index dac93c146e0..d886e8ca4d8 100644 --- a/src/main/github/github-enterprise-repository.test.ts +++ b/src/main/github/github-enterprise-repository.test.ts @@ -15,6 +15,7 @@ vi.mock('../git/runner', () => ({ ghExecFileAsync: ghExecFileAsyncMock, gitExecFileAsync: gitExecFileAsyncMock })) +vi.mock('../git/github-cli-runner', () => ({ ghExecFileAsync: ghExecFileAsyncMock })) vi.mock('../ssh/ssh-g-config-resolution', () => ({ resolveWithSshG: resolveWithSshGMock diff --git a/src/main/github/github-enterprise-repository.ts b/src/main/github/github-enterprise-repository.ts index d32dfadb3b9..b271e5b6b08 100644 --- a/src/main/github/github-enterprise-repository.ts +++ b/src/main/github/github-enterprise-repository.ts @@ -1,4 +1,4 @@ -import { ghExecFileAsync } from '../git/runner' +import { ghExecFileAsync } from '../git/github-cli-runner' import type { GitHubOwnerRepo } from '../../shared/github/pull-request-types' import { getHostedReviewLocalGitOptions, diff --git a/src/main/github/github-pr-stack-async-merge.ts b/src/main/github/github-pr-stack-async-merge.ts index d5375a468ac..4991d839037 100644 --- a/src/main/github/github-pr-stack-async-merge.ts +++ b/src/main/github/github-pr-stack-async-merge.ts @@ -1,5 +1,5 @@ import type { GitHubPRMergeMethod } from '../../shared/github/pull-request-types' -import { ghExecFileAsync } from '../git/runner' +import { ghExecFileAsync } from '../git/github-cli-runner' import { acquire, release } from './gh-utils' import { githubHostExecOptions, diff --git a/src/main/github/github-pr-stack.test.ts b/src/main/github/github-pr-stack.test.ts index 243fdc057ab..f6e4dcdd99e 100644 --- a/src/main/github/github-pr-stack.test.ts +++ b/src/main/github/github-pr-stack.test.ts @@ -7,7 +7,7 @@ const { ghExecFileAsyncMock, rateLimitGuardMock, noteRateLimitSpendMock } = vi.h noteRateLimitSpendMock: vi.fn() })) -vi.mock('../git/runner', () => ({ ghExecFileAsync: ghExecFileAsyncMock })) +vi.mock('../git/github-cli-runner', () => ({ ghExecFileAsync: ghExecFileAsyncMock })) vi.mock('./rate-limit', () => ({ repositoryRateLimitGuard: rateLimitGuardMock, noteRepositoryRateLimitSpend: noteRateLimitSpendMock diff --git a/src/main/github/github-pr-stack.ts b/src/main/github/github-pr-stack.ts index 3dc65f6175a..87243f0b20e 100644 --- a/src/main/github/github-pr-stack.ts +++ b/src/main/github/github-pr-stack.ts @@ -7,7 +7,7 @@ import type { PRState } from '../../shared/github/pull-request-types' import { githubRepoIdentityKey } from '../../shared/github/repository-identity-key' -import { ghExecFileAsync } from '../git/runner' +import { ghExecFileAsync } from '../git/github-cli-runner' import { githubHostExecOptions, type GitHubApiRepository, diff --git a/src/main/github/github-repository-identity.fork-owner-repo.test.ts b/src/main/github/github-repository-identity.fork-owner-repo.test.ts index 5461b838877..62a08f657df 100644 --- a/src/main/github/github-repository-identity.fork-owner-repo.test.ts +++ b/src/main/github/github-repository-identity.fork-owner-repo.test.ts @@ -19,6 +19,7 @@ vi.mock('../git/runner', async (importOriginal) => ({ gitExecFileAsync: gitExecFileAsyncMock, ghExecFileAsync: ghExecFileAsyncMock })) +vi.mock('../git/github-cli-runner', () => ({ ghExecFileAsync: ghExecFileAsyncMock })) vi.mock('../providers/ssh-git-dispatch', () => ({ getSshGitProvider: () => null diff --git a/src/main/github/issue-comment.ts b/src/main/github/issue-comment.ts index ac3f6a205de..340e8ba90dc 100644 --- a/src/main/github/issue-comment.ts +++ b/src/main/github/issue-comment.ts @@ -1,7 +1,8 @@ +import { ghExecFileAsync } from '../git/github-cli-runner' import type { GitHubCommentResult, PRComment } from '../../shared/github/comment-types' import type { LocalGitExecOptions, OwnerRepo } from './gh-utils' import { getIssueGitHubApiRepository, resolveGitHubRepoExecution } from './github-api-repository' -import { acquire, classifyGhError, ghExecFileAsync, release } from './gh-utils' +import { acquire, classifyGhError, release } from './gh-utils' /** * Add a comment to an existing GitHub issue. diff --git a/src/main/github/issue-create.ts b/src/main/github/issue-create.ts index 3b8af028706..566400fd70d 100644 --- a/src/main/github/issue-create.ts +++ b/src/main/github/issue-create.ts @@ -1,3 +1,4 @@ +import { ghExecFileAsync } from '../git/github-cli-runner' import type { GitHubCreateIssueFields, GitHubCreateIssueResult @@ -8,7 +9,7 @@ import { resolveGitHubRepoExecution, resolveIssueGitHubApiRepositorySource } from './github-api-repository' -import { acquire, extractExecError, ghExecFileAsync, release } from './gh-utils' +import { acquire, extractExecError, release } from './gh-utils' function githubIssueErrorMessage(error: unknown): string { const { stderr, stdout } = extractExecError(error) diff --git a/src/main/github/issue-field-options.ts b/src/main/github/issue-field-options.ts index a4d2aae32f4..c33af4324ee 100644 --- a/src/main/github/issue-field-options.ts +++ b/src/main/github/issue-field-options.ts @@ -1,3 +1,4 @@ +import { ghExecFileAsync } from '../git/github-cli-runner' import type { GitHubAssignableUser } from '../../shared/github/pull-request-types' import type { IssueSourcePreference } from '../../shared/repo-types' import type { LocalGitExecOptions } from './gh-utils' @@ -5,7 +6,7 @@ import { resolveGitHubRepoExecution, resolveIssueGitHubApiRepositorySource } from './github-api-repository' -import { acquire, ghExecFileAsync, release } from './gh-utils' +import { acquire, release } from './gh-utils' export async function listLabels( repoPath: string, diff --git a/src/main/github/issue-update.ts b/src/main/github/issue-update.ts index 801c12b79c5..4199ef691a4 100644 --- a/src/main/github/issue-update.ts +++ b/src/main/github/issue-update.ts @@ -1,7 +1,8 @@ +import { ghExecFileAsync } from '../git/github-cli-runner' import type { GitHubIssueUpdate } from '../../shared/issue-mutation-types' import type { LocalGitExecOptions } from './gh-utils' import { getIssueGitHubApiRepository, resolveGitHubRepoExecution } from './github-api-repository' -import { acquire, classifyGhError, ghExecFileAsync, release } from './gh-utils' +import { acquire, classifyGhError, release } from './gh-utils' /** * Update an existing GitHub issue. Fans out to separate gh commands for diff --git a/src/main/github/issues.test.ts b/src/main/github/issues.test.ts index f70443dea0e..e34439670e1 100644 --- a/src/main/github/issues.test.ts +++ b/src/main/github/issues.test.ts @@ -1,3 +1,4 @@ +vi.mock('../git/github-cli-runner', () => ({ ghExecFileAsync: ghExecFileAsyncMock })) import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as GithubApiRepositoryModule from './github-api-repository' import type * as GithubEnterpriseRepositoryModule from './github-enterprise-repository' diff --git a/src/main/github/issues.ts b/src/main/github/issues.ts index a9f24107393..8f8d6c886a5 100644 --- a/src/main/github/issues.ts +++ b/src/main/github/issues.ts @@ -1,3 +1,4 @@ +import { ghExecFileAsync } from '../git/github-cli-runner' import type { ClassifiedError } from '../../shared/classified-error' import type { IssueInfo } from '../../shared/github/pull-request-types' import type { IssueSourcePreference } from '../../shared/repo-types' @@ -9,7 +10,7 @@ import { resolveIssueGitHubApiRepositorySource } from './github-api-repository' // prettier-ignore -import { ghExecFileAsync, acquire, release, classifyListIssuesError } from './gh-utils' +import { acquire, release, classifyListIssuesError } from './gh-utils' // Why: distinguishes a successful-empty listing from a failed fetch. The // previous `catch { return [] }` conflated a 403 on a private upstream with an diff --git a/src/main/github/merged-pr-commit-membership.ts b/src/main/github/merged-pr-commit-membership.ts index dd595da69f4..d91248cd099 100644 --- a/src/main/github/merged-pr-commit-membership.ts +++ b/src/main/github/merged-pr-commit-membership.ts @@ -1,4 +1,4 @@ -import { ghExecFileAsync } from './gh-utils' +import { ghExecFileAsync } from '../git/github-cli-runner' import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from './rate-limit' import { githubHostExecOptions } from './github-api-repository' import { githubRepoIdentityKey } from '../../shared/github/repository-identity-key' diff --git a/src/main/github/pr-refresh-candidate-policy.ts b/src/main/github/pr-refresh-candidate-policy.ts new file mode 100644 index 00000000000..e6ca4a47bfc --- /dev/null +++ b/src/main/github/pr-refresh-candidate-policy.ts @@ -0,0 +1,155 @@ +import type { + GitHubPRRefreshAlias, + GitHubPRRefreshCandidate, + GitHubPRRefreshReason, + GitHubPRRefreshSkippedReason, + PRRefreshOutcome +} from '../../shared/github/pull-request-refresh-types' +import { NO_REVIEW_REFRESH_INTERVAL_MS } from '../source-control/hosted-review-refresh-pacing' + +const MIN_BACKGROUND_REFRESH_AGE_MS = 60_000 +const MERGEABILITY_PENDING_REFRESH_MS = 10_000 +export const MANUAL_MERGEABILITY_PENDING_REFRESH_MS = 2_500 +export const POST_PUSH_DELAY_MS = 2_500 + +export 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}::${runtimeScope}::${candidate.repoPath}::pr::${candidate.linkedPRNumber}` + } + return `${connectionScope}::${runtimeScope}::${candidate.repoPath}::branch::${candidate.branch}` +} + +export function validateCandidate( + candidate: GitHubPRRefreshCandidate +): GitHubPRRefreshSkippedReason | null { + if (candidate.repoKind !== 'git') { + return 'not-git' + } + if (candidate.isBare) { + return 'bare' + } + if (candidate.isArchived) { + return 'archived' + } + if (candidate.connectionId && candidate.connectionState === 'disconnected') { + return 'disconnected' + } + if (!candidate.branch && typeof candidate.linkedPRNumber !== 'number') { + return 'fresh' + } + return null +} + +export function bypassesFreshnessDelay(reason: GitHubPRRefreshReason): boolean { + return reason === 'manual' || reason === 'active' || reason === 'post-push' +} + +export function isBackground(reason: GitHubPRRefreshReason): boolean { + return reason !== 'manual' +} + +export function isBudgetedBackground(reason: GitHubPRRefreshReason): boolean { + return reason === 'visible' || reason === 'swr' +} + +export function shouldBroadcastQueued(reason: GitHubPRRefreshReason, dueAt: number): boolean { + if (isBudgetedBackground(reason)) { + return false + } + const delay = dueAt - Date.now() + return delay > 0 && delay <= 5_000 +} + +export function refreshIntervalForCandidate(candidate: GitHubPRRefreshCandidate): number { + if (candidate.cachedPRState === 'closed' || candidate.cachedPRState === 'merged') { + return 30 * 60_000 + } + if (candidate.cachedHasPR === false) { + return NO_REVIEW_REFRESH_INTERVAL_MS + } + if ( + candidate.cachedHasPR === true && + candidate.cachedPRState === 'open' && + candidate.cachedMergeable === 'UNKNOWN' && + !hasResolvedMergeStateStatus(candidate.cachedMergeStateStatus) + ) { + return MERGEABILITY_PENDING_REFRESH_MS + } + if (candidate.cachedChecksStatus === 'success') { + return 10 * 60_000 + } + if (candidate.cachedChecksStatus === 'failure') { + return 3 * 60_000 + } + if (candidate.cachedChecksStatus === 'pending') { + return 90_000 + } + return MIN_BACKGROUND_REFRESH_AGE_MS +} + +export function shouldSkipFresh( + candidate: GitHubPRRefreshCandidate, + reason: GitHubPRRefreshReason +): boolean { + if (bypassesFreshnessDelay(reason) || candidate.cachedFetchedAt == null) { + return false + } + return Date.now() - candidate.cachedFetchedAt < refreshIntervalForCandidate(candidate) +} + +export function freshRetryAt(candidate: GitHubPRRefreshCandidate): number | null { + return candidate.cachedFetchedAt == null + ? null + : candidate.cachedFetchedAt + refreshIntervalForCandidate(candidate) +} + +export function aliasFromCandidate(candidate: GitHubPRRefreshCandidate): GitHubPRRefreshAlias { + return { + cacheKey: candidate.cacheKey, + repoId: candidate.repoId, + repoPath: candidate.repoPath, + branch: candidate.branch, + worktreeId: candidate.worktreeId, + connectionId: candidate.connectionId ?? null, + currentHeadOid: candidate.currentHeadOid ?? null, + linkedPRNumber: candidate.linkedPRNumber ?? null, + fallbackPRNumber: + candidate.linkedPRNumber == null ? (candidate.fallbackPRNumber ?? null) : null, + fallbackPRSource: candidate.linkedPRNumber == null ? (candidate.fallbackPRSource ?? null) : null + } +} + +export function visibleCandidateAfterOutcome( + candidate: GitHubPRRefreshCandidate, + outcome: PRRefreshOutcome +): GitHubPRRefreshCandidate { + if (outcome.kind === 'upstream-error') { + return candidate + } + return { + ...candidate, + cachedFetchedAt: outcome.fetchedAt, + cachedHasPR: outcome.kind === 'found', + cachedPRState: outcome.kind === 'found' ? outcome.pr.state : null, + cachedChecksStatus: outcome.kind === 'found' ? outcome.pr.checksStatus : null, + cachedMergeable: outcome.kind === 'found' ? outcome.pr.mergeable : null, + cachedMergeStateStatus: outcome.kind === 'found' ? (outcome.pr.mergeStateStatus ?? null) : null + } +} + +function hasResolvedMergeStateStatus(status: string | null | undefined): boolean { + return status === 'CLEAN' || status === 'BEHIND' || status === 'BLOCKED' +} + +export function isMergeabilityPendingOutcome(outcome: PRRefreshOutcome): boolean { + return ( + outcome.kind === 'found' && + outcome.pr.state === 'open' && + outcome.pr.mergeable === 'UNKNOWN' && + !hasResolvedMergeStateStatus(outcome.pr.mergeStateStatus) + ) +} diff --git a/src/main/github/pr-refresh-coordinator.ts b/src/main/github/pr-refresh-coordinator.ts index 2ff9d0e410b..cdac97f2611 100644 --- a/src/main/github/pr-refresh-coordinator.ts +++ b/src/main/github/pr-refresh-coordinator.ts @@ -1,817 +1,60 @@ -/* eslint-disable max-lines -- Why: queueing, pacing, and broadcast rules stay together so freshness and rate-limit invariants are reviewable in one place. */ -import { webContents } from 'electron' import type { - GitHubPRRefreshAlias, GitHubPRRefreshCandidate, - GitHubPRRefreshEvent, GitHubPRRefreshReason, - GitHubPRRefreshSkippedReason, PRRefreshOutcome } from '../../shared/github/pull-request-refresh-types' -import { getPRForBranchOutcome, type GitHubPRBranchLookupOptions } from './client' -import { getOriginGitHubApiRepository } from './github-api-repository' -import { ghRepoExecOptions, githubRepoContext } from './gh-utils' -import { getRateLimit, repositoryRateLimitGuard, spendsSharedGitHubComQuota } from './rate-limit' import { - lookupBackoffDelayMs, - NO_REVIEW_REFRESH_INTERVAL_MS -} from '../source-control/hosted-review-refresh-pacing' -import { recordCoalescedCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' -import { sendToTrustedUIRenderer } from '../ipc/ui' - -type QueueEntry = { - key: string - candidate: GitHubPRRefreshCandidate - aliases: Map - reason: GitHubPRRefreshReason - priority: number - dueAt: number - queuedAt: number - bypassBackgroundBudget?: boolean - activeDelayNotified?: boolean - windowId?: number -} - -type PRRefreshOutcomeObserver = ( - candidate: GitHubPRRefreshCandidate, - outcome: PRRefreshOutcome -) => void - -type PRBranchLookupCandidate = Pick< - GitHubPRRefreshCandidate, - 'localGitOptions' | 'linkedPRNumber' | 'fallbackPRNumber' | 'fallbackPRSource' | 'currentHeadOid' -> - -function shouldAcceptMergedFallbackPR(candidate: PRBranchLookupCandidate): boolean { - return ( - candidate.linkedPRNumber == null && - candidate.fallbackPRNumber != null && - candidate.fallbackPRSource != null - ) -} - -function hostedReviewOptionArgs( - candidate: PRBranchLookupCandidate -): [] | [GitHubPRBranchLookupOptions] { - const options: GitHubPRBranchLookupOptions = {} - if (candidate.localGitOptions?.wslDistro) { - options.localGitExecOptions = { wslDistro: candidate.localGitOptions.wslDistro } - } - if (shouldAcceptMergedFallbackPR(candidate)) { - options.acceptMergedFallbackPR = true - } - if (typeof candidate.currentHeadOid === 'string' && candidate.currentHeadOid.trim().length > 0) { - options.currentHeadOid = candidate.currentHeadOid.trim() - } - return Object.keys(options).length > 0 ? [options] : [] -} - -const MIN_BACKGROUND_REFRESH_AGE_MS = 60_000 -const MERGEABILITY_PENDING_REFRESH_MS = 10_000 -const MANUAL_MERGEABILITY_PENDING_REFRESH_MS = 2_500 -const BACKGROUND_BUDGET_WINDOW_MS = 5 * 60_000 -const MIN_BACKGROUND_SPACING_MS = 10_000 -const BACKGROUND_BUDGET_MAX = 20 -const POST_PUSH_DELAY_MS = 2_500 -const DIAGNOSTIC_BREADCRUMB_MIN_INTERVAL_MS = 30_000 -const ACTIVE_BURST_WINDOW_MS = 30_000 -const ACTIVE_BURST_MAX = 3 - -let sequence = 0 -let queueOrder = 0 -let draining = false -let drainTimer: ReturnType | null = null -const queue = new Map() -const backgroundStarts: number[] = [] -const activeStartsByScope = new Map() -const errorBackoff = new Map() -// Per-key manual-retry cooldown (secondary rate-limit Retry-After); refreshPRNow refuses manual retry until this time so a stale renderer can't bypass it. -const manualRetryGates = new Map() -let lastBackgroundStartAt = 0 - -/** - * Track (or clear) the manual-retry cooldown for a key from a broadcast outcome. - * Only a rate-limit outcome carrying `retryDisabledUntil` sets a gate; any other settled outcome clears it. - */ -function noteManualRetryGate(key: string, outcome: PRRefreshOutcome): void { - if (outcome.kind === 'upstream-error' && outcome.retryDisabledUntil !== undefined) { - manualRetryGates.set(key, outcome.retryDisabledUntil) - } else { - manualRetryGates.delete(key) - } -} - -/** Reset all per-key retry state (backoff + manual cooldown) when a key resets. */ -function resetKeyRetryState(key: string): void { - errorBackoff.delete(key) - manualRetryGates.delete(key) -} -const visibleByWindow = new Map }>() -let outcomeObserver: PRRefreshOutcomeObserver | null = null -const diagnosticsCounters = { - enqueued: 0, - coalesced: 0, - skipped: 0, - backgroundPauses: 0 -} + aliasFromCandidate, + bypassesFreshnessDelay, + freshRetryAt, + MANUAL_MERGEABILITY_PENDING_REFRESH_MS, + POST_PUSH_DELAY_MS, + refreshKey, + shouldBroadcastQueued, + shouldSkipFresh, + validateCandidate +} from './pr-refresh-candidate-policy' +import { getGitHubRefreshRateLimitRetryAt, queryGitHubPullRequest } from './pr-refresh-github-query' +import { clearActiveRefreshBurstWindow } from './pr-refresh-pacing' +import { schedulePRRefreshDrain } from './pr-refresh-queue-drain' +import { + broadcastRefreshEvent, + isRefreshKeyVisible, + manualRetryGateUntil, + nextRefreshQueueOrder, + nextRefreshSequence, + nextVisibleErrorRetryAt, + noteManualRetryGate, + notifyRefreshOutcome, + pruneWorktreeRefreshAliases, + recordRefreshQueueDiagnostic, + refreshErrorBackoffCount, + refreshQueue, + refreshQueueAliasCount, + removeInvisibleVisibleRefreshes, + removeQueuedAliasForInvalidCandidate, + setRefreshOutcomeObserver, + visibleRefreshesByWindow, + visibleRefreshWindowCount, + withErrorSchedule, + type PRRefreshOutcomeObserver +} from './pr-refresh-queue-state' +import { scheduleVisiblePRRefreshFollowUp } from './pr-refresh-visible-follow-up' export function setPRRefreshOutcomeObserver(observer: PRRefreshOutcomeObserver | null): void { - outcomeObserver = observer -} - -function removeInvisibleVisibleRefreshes(): void { - for (const [key, entry] of queue) { - if (entry.reason === 'visible' && !isVisibleKey(key)) { - queue.delete(key) - resetKeyRetryState(key) - broadcast({ - aliases: Array.from(entry.aliases.values()), - reason: 'visible', - status: 'skipped', - skippedReason: 'fresh' - }) - } - } -} - -function recordPRRefreshQueueDiagnostic( - event: 'enqueued' | 'coalesced' | 'skipped' | 'background-pause', - reason: GitHubPRRefreshReason, - skippedReason?: GitHubPRRefreshSkippedReason -): void { - recordCoalescedCrashBreadcrumb({ - name: 'pr_refresh_queue', - coalesceKey: `pr-refresh-queue:${event}:${reason}:${skippedReason ?? ''}`, - minIntervalMs: DIAGNOSTIC_BREADCRUMB_MIN_INTERVAL_MS, - data: { - event, - reason, - ...(skippedReason ? { skippedReason } : {}), - enqueued: diagnosticsCounters.enqueued, - coalesced: diagnosticsCounters.coalesced, - skipped: diagnosticsCounters.skipped, - backgroundPauses: diagnosticsCounters.backgroundPauses - } - }) -} - -function clearActiveBurstWindow(windowId: number): void { - const windowPrefix = `${windowId}::` - for (const scope of Array.from(activeStartsByScope.keys())) { - if (scope.startsWith(windowPrefix)) { - activeStartsByScope.delete(scope) - } - } + setRefreshOutcomeObserver(observer) } export function clearVisiblePRRefreshWindow(windowId: number): void { - const hadVisibleRefreshes = visibleByWindow.delete(windowId) - clearActiveBurstWindow(windowId) + const hadVisibleRefreshes = visibleRefreshesByWindow.delete(windowId) + clearActiveRefreshBurstWindow(windowId) if (hadVisibleRefreshes) { - // Why: visible follow-ups are owned by the reporting renderer; if its WebContents is destroyed, no later visibility report arrives. removeInvisibleVisibleRefreshes() } } -/** - * Drop a removed worktree's aliases from every queue entry. - * - * Why: aliases are otherwise only pruned when a candidate is re-enqueued as invalid, so churning worktrees grow these maps unbounded (OOM creep). - */ export function pruneWorktreePRRefreshAliases(worktreeId: string): void { - for (const [key, entry] of queue) { - let removed = false - for (const [cacheKey, alias] of entry.aliases) { - if (alias.worktreeId === worktreeId) { - entry.aliases.delete(cacheKey) - removed = true - } - } - if (!removed) { - continue - } - // No aliases left means no worktree still cares about this refresh. - if (entry.aliases.size === 0) { - queue.delete(key) - resetKeyRetryState(key) - continue - } - // Keep the entry alive but replace its representative so it isn't a dangling reference to the removed worktree. - if (entry.candidate.worktreeId === worktreeId) { - const replacementAlias = entry.aliases.values().next().value - if (replacementAlias) { - entry.candidate = { - ...entry.candidate, - cacheKey: replacementAlias.cacheKey, - branch: replacementAlias.branch, - worktreeId: replacementAlias.worktreeId, - // Why: the probe now represents the replacement worktree, so use its head — else the removed worktree's link never clears. - currentHeadOid: replacementAlias.currentHeadOid ?? null - } - } - } - } -} - -function nextSequence(): number { - sequence += 1 - return sequence -} - -function nextQueueOrder(): number { - queueOrder += 1 - return queueOrder -} - -function broadcast(event: Omit, sequenceOverride?: number): void { - const payload = { ...event, sequence: sequenceOverride ?? nextSequence() } as GitHubPRRefreshEvent - sendToTrustedUIRenderer('gh:prRefreshEvent', payload) -} - -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}::${runtimeScope}::${candidate.repoPath}::pr::${candidate.linkedPRNumber}` - } - return `${connectionScope}::${runtimeScope}::${candidate.repoPath}::branch::${candidate.branch}` -} - -function isVisibleKey(key: string): boolean { - const liveWindowIds = new Set( - webContents - .getAllWebContents() - .filter((wc) => !wc.isDestroyed()) - .map((wc) => wc.id) - ) - for (const windowId of Array.from(visibleByWindow.keys())) { - if (!liveWindowIds.has(windowId)) { - visibleByWindow.delete(windowId) - } - } - for (const visible of visibleByWindow.values()) { - if (visible.keys.has(key)) { - return true - } - } - return false -} - -function isManual(reason: GitHubPRRefreshReason): boolean { - return reason === 'manual' -} - -function bypassesFreshnessDelay(reason: GitHubPRRefreshReason): boolean { - return reason === 'manual' || reason === 'active' || reason === 'post-push' -} - -function isBackground(reason: GitHubPRRefreshReason): boolean { - return reason !== 'manual' -} - -function isBudgetedBackground(reason: GitHubPRRefreshReason): boolean { - return reason === 'visible' || reason === 'swr' -} - -function isBudgetedQueueEntry(entry: QueueEntry): boolean { - return isBudgetedBackground(entry.reason) && entry.bypassBackgroundBudget !== true -} - -function validateCandidate( - candidate: GitHubPRRefreshCandidate -): GitHubPRRefreshSkippedReason | null { - if (candidate.repoKind !== 'git') { - return 'not-git' - } - if (candidate.isBare) { - return 'bare' - } - if (candidate.isArchived) { - return 'archived' - } - if (candidate.connectionId && candidate.connectionState === 'disconnected') { - return 'disconnected' - } - if (!candidate.branch && typeof candidate.linkedPRNumber !== 'number') { - return 'fresh' - } - return null -} - -function shouldSkipFresh( - candidate: GitHubPRRefreshCandidate, - reason: GitHubPRRefreshReason -): boolean { - if (bypassesFreshnessDelay(reason) || candidate.cachedFetchedAt == null) { - return false - } - return Date.now() - candidate.cachedFetchedAt < refreshIntervalForCandidate(candidate) -} - -function shouldBroadcastQueued(reason: GitHubPRRefreshReason, dueAt: number): boolean { - if (isBudgetedBackground(reason)) { - return false - } - const delay = dueAt - Date.now() - if (delay <= 0) { - return false - } - return delay <= 5_000 -} - -function freshRetryAt(candidate: GitHubPRRefreshCandidate): number | null { - return candidate.cachedFetchedAt == null - ? null - : candidate.cachedFetchedAt + refreshIntervalForCandidate(candidate) -} - -function aliasFromCandidate(candidate: GitHubPRRefreshCandidate): GitHubPRRefreshAlias { - return { - cacheKey: candidate.cacheKey, - repoId: candidate.repoId, - repoPath: candidate.repoPath, - branch: candidate.branch, - worktreeId: candidate.worktreeId, - connectionId: candidate.connectionId ?? null, - currentHeadOid: candidate.currentHeadOid ?? null, - linkedPRNumber: candidate.linkedPRNumber ?? null, - fallbackPRNumber: - candidate.linkedPRNumber == null ? (candidate.fallbackPRNumber ?? null) : null, - fallbackPRSource: candidate.linkedPRNumber == null ? (candidate.fallbackPRSource ?? null) : null - } -} - -function visibleCandidateAfterOutcome( - candidate: GitHubPRRefreshCandidate, - outcome: PRRefreshOutcome -): GitHubPRRefreshCandidate { - if (outcome.kind === 'upstream-error') { - return candidate - } - return { - ...candidate, - cachedFetchedAt: outcome.fetchedAt, - cachedHasPR: outcome.kind === 'found', - cachedPRState: outcome.kind === 'found' ? outcome.pr.state : null, - cachedChecksStatus: outcome.kind === 'found' ? outcome.pr.checksStatus : null, - cachedMergeable: outcome.kind === 'found' ? outcome.pr.mergeable : null, - cachedMergeStateStatus: outcome.kind === 'found' ? (outcome.pr.mergeStateStatus ?? null) : null - } -} - -function setVisibleFollowUp(entry: QueueEntry): void { - const existing = queue.get(entry.key) - if (!existing) { - queue.set(entry.key, entry) - return - } - - for (const alias of entry.aliases.values()) { - existing.aliases.set(alias.cacheKey, alias) - } - - // Why: a user activation can arrive while a background refresh awaits gh; the follow-up must not overwrite that pending active/manual work. - if ( - bypassesFreshnessDelay(existing.reason) || - existing.priority > entry.priority || - existing.dueAt <= entry.dueAt - ) { - return - } - - queue.set(entry.key, { - ...entry, - aliases: existing.aliases - }) -} - -function removeQueuedAliasForInvalidCandidate(key: string, alias: GitHubPRRefreshAlias): void { - const existing = queue.get(key) - if (!existing) { - return - } - - existing.aliases.delete(alias.cacheKey) - const replacementAlias = existing.aliases.values().next().value - if (!replacementAlias) { - queue.delete(key) - resetKeyRetryState(key) - return - } - - if (existing.candidate.cacheKey === alias.cacheKey) { - existing.candidate = { - ...existing.candidate, - cacheKey: replacementAlias.cacheKey, - branch: replacementAlias.branch, - worktreeId: replacementAlias.worktreeId, - // Why: the probe now represents the replacement worktree, so use its head — else the survivor's link never clears. - currentHeadOid: replacementAlias.currentHeadOid ?? null, - isArchived: false, - isBare: false - } - } -} - -/** - * Advances the visible-key error backoff and returns the earliest retry time. - * Why: only visible keys auto-retry, so callers must gate on `isVisibleKey` first. - */ -function nextVisibleErrorRetryAt(key: string): number { - const failures = (errorBackoff.get(key)?.failures ?? 0) + 1 - const retryAt = Date.now() + lookupBackoffDelayMs(failures) - errorBackoff.set(key, { failures, retryAt }) - return retryAt -} - -/** - * Stamps the auto-retry time onto an error outcome before broadcast. - * Why: sets only `nextAutoRetryAt`, never `retryDisabledUntil` — that disables *manual* Retry and is reserved for a real rate-limit gate. - */ -function withErrorSchedule(outcome: PRRefreshOutcome, retryAt: number): PRRefreshOutcome { - if (outcome.kind !== 'upstream-error') { - return outcome - } - // Why: honor a real Retry-After cooldown the client stamped (`retryDisabledUntil`) so we don't auto-retry into an active secondary rate limit. - const cooldownUntil = outcome.retryDisabledUntil - return { - ...outcome, - nextAutoRetryAt: cooldownUntil !== undefined ? Math.max(retryAt, cooldownUntil) : retryAt - } -} - -function scheduleVisibleFollowUp( - key: string, - candidate: GitHubPRRefreshCandidate, - outcome: PRRefreshOutcome, - priority: number, - aliases: GitHubPRRefreshAlias[], - windowId?: number, - options?: { pendingMergeabilityDelayMs?: number; plannedRetryAt?: number } -): void { - if (!isVisibleKey(key)) { - // Why: manual/active refreshes can remove the queued visible retry after its owner window is gone, orphaning the backoff. - resetKeyRetryState(key) - return - } - if (outcome.kind === 'upstream-error') { - // Why: reuse the retry time already computed for the broadcast so the same failure isn't counted twice against the backoff. - const retryAt = options?.plannedRetryAt ?? nextVisibleErrorRetryAt(key) - setVisibleFollowUp({ - key, - candidate, - aliases: new Map(aliases.map((alias) => [alias.cacheKey, alias])), - reason: 'visible', - priority, - dueAt: retryAt, - queuedAt: nextQueueOrder(), - windowId - }) - // Why: this is a delayed retry, not active work; a spinner would make visible worktrees look stuck until backoff expires. - scheduleDrain(retryAt - Date.now()) - return - } - resetKeyRetryState(key) - const followUpCandidate = visibleCandidateAfterOutcome(candidate, outcome) - const regularDueAt = freshRetryAt(followUpCandidate) ?? Date.now() - const pendingMergeabilityDueAt = - options?.pendingMergeabilityDelayMs !== undefined && isMergeabilityPendingOutcome(outcome) - ? outcome.fetchedAt + options.pendingMergeabilityDelayMs - : null - const dueAt = - pendingMergeabilityDueAt === null - ? regularDueAt - : Math.min(regularDueAt, pendingMergeabilityDueAt) - // Why: a coalesced linked-PR refresh may represent several branches; preserve every alias so all cache entries keep getting updates. - setVisibleFollowUp({ - key, - candidate: followUpCandidate, - aliases: new Map(aliases.map((alias) => [alias.cacheKey, alias])), - reason: 'visible', - priority, - dueAt, - queuedAt: nextQueueOrder(), - // Why: this manual one-shot fixes GitHub's transient UNKNOWN state; visible spacing would delay it past the prompt retry window. - bypassBackgroundBudget: pendingMergeabilityDueAt !== null, - windowId - }) - scheduleDrain(Math.max(0, dueAt - Date.now())) -} - -function refreshIntervalForCandidate(candidate: GitHubPRRefreshCandidate): number { - if (candidate.cachedPRState === 'closed' || candidate.cachedPRState === 'merged') { - return 30 * 60_000 - } - if (candidate.cachedHasPR === false) { - return NO_REVIEW_REFRESH_INTERVAL_MS - } - if ( - candidate.cachedHasPR === true && - candidate.cachedPRState === 'open' && - candidate.cachedMergeable === 'UNKNOWN' && - !hasResolvedMergeStateStatus(candidate.cachedMergeStateStatus) - ) { - // Why: GitHub returns transient UNKNOWN mergeability while computing the test merge; visible merge buttons need a prompt follow-up. - return MERGEABILITY_PENDING_REFRESH_MS - } - if (candidate.cachedChecksStatus === 'success') { - return 10 * 60_000 - } - if (candidate.cachedChecksStatus === 'failure') { - return 3 * 60_000 - } - if (candidate.cachedChecksStatus === 'pending') { - return 90_000 - } - return MIN_BACKGROUND_REFRESH_AGE_MS -} - -function hasResolvedMergeStateStatus(status: string | null | undefined): boolean { - return status === 'CLEAN' || status === 'BEHIND' || status === 'BLOCKED' -} - -function isMergeabilityPendingOutcome(outcome: PRRefreshOutcome): boolean { - return ( - outcome.kind === 'found' && - outcome.pr.state === 'open' && - outcome.pr.mergeable === 'UNKNOWN' && - !hasResolvedMergeStateStatus(outcome.pr.mergeStateStatus) - ) -} - -function backgroundRefreshBuckets(): ('core' | 'graphql')[] { - // Why: branch refreshes prefer REST but can fall back to `gh pr list`, so guard both buckets until the client exposes a per-lookup cost plan. - return ['core', 'graphql'] -} - -function noteBackgroundStart(): void { - const now = Date.now() - lastBackgroundStartAt = now - backgroundStarts.push(now) - while (backgroundStarts.length > 0 && now - backgroundStarts[0] > BACKGROUND_BUDGET_WINDOW_MS) { - backgroundStarts.shift() - } -} - -function nextBudgetDelay(): number { - const now = Date.now() - while (backgroundStarts.length > 0 && now - backgroundStarts[0] > BACKGROUND_BUDGET_WINDOW_MS) { - backgroundStarts.shift() - } - const spacingDelay = - lastBackgroundStartAt > 0 - ? Math.max(0, MIN_BACKGROUND_SPACING_MS - (now - lastBackgroundStartAt)) - : 0 - const windowDelay = - backgroundStarts.length < BACKGROUND_BUDGET_MAX - ? 0 - : Math.max(1_000, BACKGROUND_BUDGET_WINDOW_MS - (now - backgroundStarts[0])) - return Math.max(spacingDelay, windowDelay) -} - -function activeBurstScope(entry: QueueEntry): string { - const runtimeScope = entry.candidate.connectionId - ? `ssh:${entry.candidate.connectionId}` - : `local:${entry.candidate.localGitOptions?.wslDistro ?? 'host'}` - return `${entry.windowId ?? 'global'}::${runtimeScope}` -} - -function pruneActiveStarts(scope: string, now: number): number[] { - const activeStarts = activeStartsByScope.get(scope) ?? [] - while (activeStarts.length > 0 && now - activeStarts[0] >= ACTIVE_BURST_WINDOW_MS) { - activeStarts.shift() - } - if (activeStarts.length === 0) { - activeStartsByScope.delete(scope) - } else { - activeStartsByScope.set(scope, activeStarts) - } - return activeStarts -} - -function nextActiveBurstDelay(entry: QueueEntry): number { - const now = Date.now() - const activeStarts = pruneActiveStarts(activeBurstScope(entry), now) - if (activeStarts.length < ACTIVE_BURST_MAX) { - return 0 - } - return Math.max(1, ACTIVE_BURST_WINDOW_MS - (now - activeStarts[0])) -} - -function noteActiveStart(entry: QueueEntry): void { - const now = Date.now() - const scope = activeBurstScope(entry) - const activeStarts = pruneActiveStarts(scope, now) - activeStarts.push(now) - activeStartsByScope.set(scope, activeStarts) -} - -function activeOrder(a: QueueEntry, b: QueueEntry): number { - if (a.reason !== 'active' || b.reason !== 'active') { - return 0 - } - if (activeBurstScope(a) !== activeBurstScope(b)) { - return 0 - } - // Why: refresh the worktree the user lands on before stale transient selections in the same window/runtime scope. - return b.queuedAt - a.queuedAt -} - -function entryDelay(entry: QueueEntry): number { - const activeBurstDelay = entry.reason === 'active' ? nextActiveBurstDelay(entry) : 0 - if (activeBurstDelay > 0) { - return activeBurstDelay - } - return isBudgetedQueueEntry(entry) ? nextBudgetDelay() : 0 -} - -function isActiveBurstDelayed(entry: QueueEntry): boolean { - return entry.reason === 'active' && nextActiveBurstDelay(entry) > 0 -} - -function nextQueuedWakeDelay(excludedKey: string): number | null { - const now = Date.now() - let nextDelay = Number.POSITIVE_INFINITY - for (const entry of queue.values()) { - if (entry.key === excludedKey) { - continue - } - const delay = entry.dueAt > now ? entry.dueAt - now : entryDelay(entry) - nextDelay = Math.min(nextDelay, delay) - } - return Number.isFinite(nextDelay) ? Math.max(0, nextDelay) : null -} - -function scheduleDrain(delay = 0): void { - if (drainTimer) { - clearTimeout(drainTimer) - } - drainTimer = setTimeout(() => { - drainTimer = null - void drainQueue() - }, delay) -} - -function queuedEntriesByPriority(): QueueEntry[] { - const now = Date.now() - return Array.from(queue.values()).sort((a, b) => { - const aReady = a.dueAt <= now - const bReady = b.dueAt <= now - if (aReady && bReady) { - return b.priority - a.priority || activeOrder(a, b) || a.dueAt - b.dueAt - } - if (aReady !== bReady) { - return aReady ? -1 : 1 - } - return a.dueAt - b.dueAt || b.priority - a.priority - }) -} - -async function drainQueue(): Promise { - if (draining) { - return - } - draining = true - try { - while (queue.size > 0) { - let next = queuedEntriesByPriority()[0] - const waitMs = next.dueAt - Date.now() - if (waitMs > 0) { - scheduleDrain(waitMs) - return - } - - let delay = entryDelay(next) - if (delay > 0) { - const runnable = queuedEntriesByPriority().find( - (entry) => entry.dueAt <= Date.now() && entryDelay(entry) === 0 - ) - if (runnable && runnable.key !== next.key) { - next = runnable - delay = 0 - } else { - if (isActiveBurstDelayed(next) && !next.activeDelayNotified) { - next.activeDelayNotified = true - broadcast({ - aliases: Array.from(next.aliases.values()), - reason: next.reason, - status: 'queued' - }) - } - if (isBudgetedQueueEntry(next) && nextBudgetDelay() > 0) { - diagnosticsCounters.backgroundPauses += 1 - recordPRRefreshQueueDiagnostic('background-pause', next.reason) - } - scheduleDrain(Math.min(delay, nextQueuedWakeDelay(next.key) ?? delay)) - return - } - } - - queue.delete(next.key) - const aliases = Array.from(next.aliases.values()) - const skippedReason = validateCandidate(next.candidate) - if (skippedReason) { - diagnosticsCounters.skipped += 1 - recordPRRefreshQueueDiagnostic('skipped', next.reason, skippedReason) - broadcast({ aliases, reason: next.reason, status: 'skipped', skippedReason }) - continue - } - if (next.reason === 'visible' && !isVisibleKey(next.key)) { - resetKeyRetryState(next.key) - broadcast({ aliases, reason: next.reason, status: 'skipped', skippedReason: 'fresh' }) - continue - } - const requestSequence = nextSequence() - const requestStartedAt = Date.now() - broadcast( - { aliases, reason: next.reason, status: 'in-flight', requestStartedAt }, - requestSequence - ) - - if (isBackground(next.reason)) { - const executionOptions = ghRepoExecOptions( - githubRepoContext( - next.candidate.repoPath, - next.candidate.connectionId, - next.candidate.localGitOptions - ) - ) - const repository = await getOriginGitHubApiRepository( - next.candidate.repoPath, - next.candidate.connectionId, - executionOptions - ) - // Why: only native github.com uses the singleton snapshot; scoped breakers protect GHES and WSL. - if (spendsSharedGitHubComQuota(repository, executionOptions)) { - // Why: the probe only warms the cache, so failures must fail open (#7553). - await getRateLimit() - } - const buckets = backgroundRefreshBuckets() - const blockedGuard = buckets - .map((bucket) => repositoryRateLimitGuard(repository, bucket, executionOptions)) - .find((guard) => guard.blocked) - if (blockedGuard?.blocked) { - const retryAt = blockedGuard.resetAt * 1000 - queue.set(next.key, { ...next, dueAt: retryAt }) - broadcast({ - aliases, - reason: next.reason, - status: 'paused', - pausedUntil: retryAt, - skippedReason: 'rate-limit' - }) - scheduleDrain(Math.max(1_000, retryAt - Date.now())) - continue - } - if (isBudgetedQueueEntry(next)) { - noteBackgroundStart() - } - if (next.reason === 'active') { - // Why: tab/worktree churn can enqueue many distinct active refreshes that each probe local Git. - noteActiveStart(next) - } - // Why (#11532): the lookup itself now debits the snapshot, so every - // caller is accounted for; debiting here too would double-count. - } - - const outcome = await getPRForBranchOutcome( - next.candidate.repoPath, - next.candidate.branch, - next.candidate.linkedPRNumber ?? null, - next.candidate.connectionId ?? null, - next.candidate.linkedPRNumber == null ? (next.candidate.fallbackPRNumber ?? null) : null, - ...hostedReviewOptionArgs(next.candidate) - ) - // Why: compute the retry schedule before broadcasting so the outcome and its follow-up share one timing; only visible keys auto-retry. - let plannedRetryAt: number | undefined - let broadcastOutcome = outcome - if (outcome.kind === 'upstream-error' && isVisibleKey(next.key)) { - plannedRetryAt = nextVisibleErrorRetryAt(next.key) - broadcastOutcome = withErrorSchedule(outcome, plannedRetryAt) - } - outcomeObserver?.(next.candidate, outcome) - noteManualRetryGate(next.key, broadcastOutcome) - broadcast( - { aliases, reason: next.reason, outcome: broadcastOutcome, requestStartedAt }, - requestSequence - ) - scheduleVisibleFollowUp( - next.key, - next.candidate, - outcome, - next.priority, - aliases, - next.windowId, - { - plannedRetryAt - } - ) - } - } finally { - draining = false - } + pruneWorktreeRefreshAliases(worktreeId) } export function enqueuePRRefresh( @@ -825,39 +68,31 @@ export function enqueuePRRefresh( const skippedReason = validateCandidate(candidate) if (skippedReason) { removeQueuedAliasForInvalidCandidate(key, alias) - diagnosticsCounters.skipped += 1 - recordPRRefreshQueueDiagnostic('skipped', reason, skippedReason) - broadcast({ - aliases: [alias], - reason, - status: 'skipped', - skippedReason - }) + recordRefreshQueueDiagnostic('skipped', reason, skippedReason) + broadcastRefreshEvent({ aliases: [alias], reason, status: 'skipped', skippedReason }) return } - const existing = queue.get(key) + const existing = refreshQueue.get(key) const freshDueAt = shouldSkipFresh(candidate, reason) ? freshRetryAt(candidate) : null const dueAt = freshDueAt ?? Date.now() + (reason === 'post-push' ? POST_PUSH_DELAY_MS : 0) if (existing) { existing.aliases.set(alias.cacheKey, alias) - diagnosticsCounters.coalesced += 1 - recordPRRefreshQueueDiagnostic('coalesced', reason) + recordRefreshQueueDiagnostic('coalesced', reason) const shouldPromoteExisting = priority > existing.priority || - isManual(reason) || + reason === 'manual' || (reason === 'active' && existing.reason === 'active') || (priority >= existing.priority && dueAt < existing.dueAt && bypassesFreshnessDelay(reason)) if (shouldPromoteExisting) { existing.priority = priority existing.reason = reason existing.dueAt = Math.min(existing.dueAt, dueAt) - existing.queuedAt = nextQueueOrder() + existing.queuedAt = nextRefreshQueueOrder() existing.activeDelayNotified = false existing.candidate = candidate existing.windowId = windowId ?? existing.windowId } else if (existing.candidate.worktreeId === candidate.worktreeId) { - // Why: the representative drives the probe head; refresh probe inputs when its worktree moved head/branch, else a merged linked PR lingers after a switch. existing.candidate = { ...existing.candidate, cacheKey: candidate.cacheKey, @@ -866,24 +101,22 @@ export function enqueuePRRefresh( } } } else { - diagnosticsCounters.enqueued += 1 - recordPRRefreshQueueDiagnostic('enqueued', reason) - queue.set(key, { + recordRefreshQueueDiagnostic('enqueued', reason) + refreshQueue.set(key, { key, candidate, aliases: new Map([[alias.cacheKey, alias]]), reason, priority, dueAt, - queuedAt: nextQueueOrder(), + queuedAt: nextRefreshQueueOrder(), windowId }) } - // Why: visible/SWR are background maintenance behind the budget queue; only user/action-driven queueing should surface in UI. if (shouldBroadcastQueued(reason, dueAt)) { - broadcast({ aliases: [alias], reason, status: 'queued' }) + broadcastRefreshEvent({ aliases: [alias], reason, status: 'queued' }) } - scheduleDrain() + schedulePRRefreshDrain() } export function reportVisiblePRRefreshCandidates( @@ -891,11 +124,14 @@ export function reportVisiblePRRefreshCandidates( generation: number, windowId: number ): void { - const existingVisible = visibleByWindow.get(windowId) + const existingVisible = visibleRefreshesByWindow.get(windowId) if (existingVisible && generation < existingVisible.generation) { return } - visibleByWindow.set(windowId, { generation, keys: new Set(candidates.map(refreshKey)) }) + visibleRefreshesByWindow.set(windowId, { + generation, + keys: new Set(candidates.map(refreshKey)) + }) removeInvisibleVisibleRefreshes() for (const candidate of candidates) { enqueuePRRefresh(candidate, 'visible', 40, windowId) @@ -903,25 +139,25 @@ export function reportVisiblePRRefreshCandidates( } export function _getVisiblePRRefreshWindowCountForTests(): number { - return visibleByWindow.size + return visibleRefreshWindowCount() } export function _getPRRefreshErrorBackoffCountForTests(): number { - return errorBackoff.size + return refreshErrorBackoffCount() } export function _getPRRefreshQueueSizeForTests(): number { - return queue.size + return refreshQueue.size } export function _getPRRefreshAliasCountForTests(key: string): number { - return queue.get(key)?.aliases.size ?? 0 + return refreshQueueAliasCount(key) } export async function refreshPRNow(candidate: GitHubPRRefreshCandidate): Promise { const alias = aliasFromCandidate(candidate) const key = refreshKey(candidate) - const existing = queue.get(key) + const existing = refreshQueue.get(key) const aliasMap = new Map(existing ? existing.aliases : []) aliasMap.set(alias.cacheKey, alias) const aliases = Array.from(aliasMap.values()) @@ -934,47 +170,32 @@ export async function refreshPRNow(candidate: GitHubPRRefreshCandidate): Promise message: `Cannot refresh PR for this worktree: ${skippedReason}`, fetchedAt: Date.now() } - broadcast({ aliases: [alias], reason: 'manual', status: 'skipped', skippedReason }) + broadcastRefreshEvent({ aliases: [alias], reason: 'manual', status: 'skipped', skippedReason }) return outcome } - // Why: enforce the rate-limit gate so a stale renderer can't bypass it; refuse without spending quota until the later of the two cooldowns. - const manualExecutionOptions = ghRepoExecOptions( - githubRepoContext(candidate.repoPath, candidate.connectionId, candidate.localGitOptions) - ) - const manualRepository = await getOriginGitHubApiRepository( - candidate.repoPath, - candidate.connectionId, - manualExecutionOptions - ) - const manualBlockedGuard = backgroundRefreshBuckets() - .map((bucket) => repositoryRateLimitGuard(manualRepository, bucket, manualExecutionOptions)) - .find((guard) => guard.blocked) - const secondaryGateUntil = manualRetryGates.get(key) - const gateUntil = Math.max( - manualBlockedGuard?.blocked ? manualBlockedGuard.resetAt * 1000 : 0, - secondaryGateUntil !== undefined ? secondaryGateUntil : 0 - ) + const primaryGateUntil = await getGitHubRefreshRateLimitRetryAt(candidate, false) + const secondaryGateUntil = manualRetryGateUntil(key) + const gateUntil = Math.max(primaryGateUntil ?? 0, secondaryGateUntil ?? 0) if (gateUntil > Date.now()) { const retryAt = gateUntil - // Why: paused maps `pausedUntil` into the renderer's auto-retry, so requeue at reset (finding 12) — don't advertise an unscheduled retry. - queue.set(key, { + refreshQueue.set(key, { key, candidate, aliases: aliasMap, reason: 'manual', priority: 40, dueAt: retryAt, - queuedAt: nextQueueOrder() + queuedAt: nextRefreshQueueOrder() }) - broadcast({ + broadcastRefreshEvent({ aliases, reason: 'manual', status: 'paused', pausedUntil: retryAt, skippedReason: 'rate-limit' }) - scheduleDrain(Math.max(1_000, retryAt - Date.now())) + schedulePRRefreshDrain(Math.max(1_000, retryAt - Date.now())) return { kind: 'upstream-error', errorType: 'rate_limited', @@ -985,34 +206,35 @@ export async function refreshPRNow(candidate: GitHubPRRefreshCandidate): Promise } } - queue.delete(key) - const requestSequence = nextSequence() + refreshQueue.delete(key) + const requestSequence = nextRefreshSequence() const requestStartedAt = Date.now() - broadcast({ aliases, reason: 'manual', status: 'in-flight', requestStartedAt }, requestSequence) - const outcome = await getPRForBranchOutcome( - candidate.repoPath, - candidate.branch, - candidate.linkedPRNumber ?? null, - candidate.connectionId ?? null, - candidate.linkedPRNumber == null ? (candidate.fallbackPRNumber ?? null) : null, - ...hostedReviewOptionArgs(candidate) + broadcastRefreshEvent( + { aliases, reason: 'manual', status: 'in-flight', requestStartedAt }, + requestSequence ) + const outcome = await queryGitHubPullRequest(candidate) let plannedRetryAt: number | undefined let broadcastOutcome = outcome - if (outcome.kind === 'upstream-error' && isVisibleKey(key)) { + if (outcome.kind === 'upstream-error' && isRefreshKeyVisible(key)) { plannedRetryAt = nextVisibleErrorRetryAt(key) broadcastOutcome = withErrorSchedule(outcome, plannedRetryAt) } - outcomeObserver?.(candidate, outcome) + notifyRefreshOutcome(candidate, outcome) noteManualRetryGate(key, broadcastOutcome) - broadcast( + broadcastRefreshEvent( { aliases, reason: 'manual', outcome: broadcastOutcome, requestStartedAt }, requestSequence ) - scheduleVisibleFollowUp(key, candidate, outcome, 40, aliases, undefined, { - plannedRetryAt, - // Why: GitHub reports UNKNOWN right after `gh pr reopen`; one prompt visible retry replaces the transient label. - pendingMergeabilityDelayMs: MANUAL_MERGEABILITY_PENDING_REFRESH_MS - }) + scheduleVisiblePRRefreshFollowUp( + key, + candidate, + outcome, + 40, + aliases, + schedulePRRefreshDrain, + undefined, + { plannedRetryAt, pendingMergeabilityDelayMs: MANUAL_MERGEABILITY_PENDING_REFRESH_MS } + ) return broadcastOutcome } diff --git a/src/main/github/pr-refresh-github-query.ts b/src/main/github/pr-refresh-github-query.ts new file mode 100644 index 00000000000..2747e4f0f0b --- /dev/null +++ b/src/main/github/pr-refresh-github-query.ts @@ -0,0 +1,69 @@ +import type { + GitHubPRRefreshCandidate, + PRRefreshOutcome +} from '../../shared/github/pull-request-refresh-types' +import { getPRForBranchOutcome, type GitHubPRBranchLookupOptions } from './client' +import { getOriginGitHubApiRepository } from './github-api-repository' +import { ghRepoExecOptions, githubRepoContext } from './gh-utils' +import { getRateLimit, repositoryRateLimitGuard, spendsSharedGitHubComQuota } from './rate-limit' + +const REFRESH_RATE_LIMIT_BUCKETS = ['core', 'graphql'] as const + +type PRBranchLookupCandidate = Pick< + GitHubPRRefreshCandidate, + 'localGitOptions' | 'linkedPRNumber' | 'fallbackPRNumber' | 'fallbackPRSource' | 'currentHeadOid' +> + +function hostedReviewOptionArgs( + candidate: PRBranchLookupCandidate +): [] | [GitHubPRBranchLookupOptions] { + const options: GitHubPRBranchLookupOptions = {} + if (candidate.localGitOptions?.wslDistro) { + options.localGitExecOptions = { wslDistro: candidate.localGitOptions.wslDistro } + } + if ( + candidate.linkedPRNumber == null && + candidate.fallbackPRNumber != null && + candidate.fallbackPRSource != null + ) { + options.acceptMergedFallbackPR = true + } + if (typeof candidate.currentHeadOid === 'string' && candidate.currentHeadOid.trim().length > 0) { + options.currentHeadOid = candidate.currentHeadOid.trim() + } + return Object.keys(options).length > 0 ? [options] : [] +} + +export async function queryGitHubPullRequest( + candidate: GitHubPRRefreshCandidate +): Promise { + return getPRForBranchOutcome( + candidate.repoPath, + candidate.branch, + candidate.linkedPRNumber ?? null, + candidate.connectionId ?? null, + candidate.linkedPRNumber == null ? (candidate.fallbackPRNumber ?? null) : null, + ...hostedReviewOptionArgs(candidate) + ) +} + +export async function getGitHubRefreshRateLimitRetryAt( + candidate: GitHubPRRefreshCandidate, + warmSharedRateLimit: boolean +): Promise { + const executionOptions = ghRepoExecOptions( + githubRepoContext(candidate.repoPath, candidate.connectionId, candidate.localGitOptions) + ) + const repository = await getOriginGitHubApiRepository( + candidate.repoPath, + candidate.connectionId, + executionOptions + ) + if (warmSharedRateLimit && spendsSharedGitHubComQuota(repository, executionOptions)) { + await getRateLimit() + } + const blockedGuard = REFRESH_RATE_LIMIT_BUCKETS.map((bucket) => + repositoryRateLimitGuard(repository, bucket, executionOptions) + ).find((guard) => guard.blocked) + return blockedGuard?.blocked ? blockedGuard.resetAt * 1000 : null +} diff --git a/src/main/github/pr-refresh-pacing.ts b/src/main/github/pr-refresh-pacing.ts new file mode 100644 index 00000000000..3905dd61ab5 --- /dev/null +++ b/src/main/github/pr-refresh-pacing.ts @@ -0,0 +1,105 @@ +import type { QueueEntry } from './pr-refresh-queue-state' +import { isBudgetedBackground } from './pr-refresh-candidate-policy' + +const BACKGROUND_BUDGET_WINDOW_MS = 5 * 60_000 +const MIN_BACKGROUND_SPACING_MS = 10_000 +const BACKGROUND_BUDGET_MAX = 20 +const ACTIVE_BURST_WINDOW_MS = 30_000 +const ACTIVE_BURST_MAX = 3 + +const backgroundStarts: number[] = [] +const activeStartsByScope = new Map() +let lastBackgroundStartAt = 0 + +export function isBudgetedQueueEntry(entry: QueueEntry): boolean { + return isBudgetedBackground(entry.reason) && entry.bypassBackgroundBudget !== true +} + +export function noteBackgroundRefreshStart(): void { + const now = Date.now() + lastBackgroundStartAt = now + backgroundStarts.push(now) + while (backgroundStarts.length > 0 && now - backgroundStarts[0] > BACKGROUND_BUDGET_WINDOW_MS) { + backgroundStarts.shift() + } +} + +export function nextBackgroundBudgetDelay(): number { + const now = Date.now() + while (backgroundStarts.length > 0 && now - backgroundStarts[0] > BACKGROUND_BUDGET_WINDOW_MS) { + backgroundStarts.shift() + } + const spacingDelay = + lastBackgroundStartAt > 0 + ? Math.max(0, MIN_BACKGROUND_SPACING_MS - (now - lastBackgroundStartAt)) + : 0 + const windowDelay = + backgroundStarts.length < BACKGROUND_BUDGET_MAX + ? 0 + : Math.max(1_000, BACKGROUND_BUDGET_WINDOW_MS - (now - backgroundStarts[0])) + return Math.max(spacingDelay, windowDelay) +} + +function activeBurstScope(entry: QueueEntry): string { + const runtimeScope = entry.candidate.connectionId + ? `ssh:${entry.candidate.connectionId}` + : `local:${entry.candidate.localGitOptions?.wslDistro ?? 'host'}` + return `${entry.windowId ?? 'global'}::${runtimeScope}` +} + +function pruneActiveStarts(scope: string, now: number): number[] { + const activeStarts = activeStartsByScope.get(scope) ?? [] + while (activeStarts.length > 0 && now - activeStarts[0] >= ACTIVE_BURST_WINDOW_MS) { + activeStarts.shift() + } + if (activeStarts.length === 0) { + activeStartsByScope.delete(scope) + } else { + activeStartsByScope.set(scope, activeStarts) + } + return activeStarts +} + +export function nextActiveBurstDelay(entry: QueueEntry): number { + const now = Date.now() + const activeStarts = pruneActiveStarts(activeBurstScope(entry), now) + if (activeStarts.length < ACTIVE_BURST_MAX) { + return 0 + } + return Math.max(1, ACTIVE_BURST_WINDOW_MS - (now - activeStarts[0])) +} + +export function noteActiveRefreshStart(entry: QueueEntry): void { + const now = Date.now() + const scope = activeBurstScope(entry) + const activeStarts = pruneActiveStarts(scope, now) + activeStarts.push(now) + activeStartsByScope.set(scope, activeStarts) +} + +export function activeRefreshOrder(a: QueueEntry, b: QueueEntry): number { + if (a.reason !== 'active' || b.reason !== 'active') { + return 0 + } + if (activeBurstScope(a) !== activeBurstScope(b)) { + return 0 + } + return b.queuedAt - a.queuedAt +} + +export function refreshEntryPacingDelay(entry: QueueEntry): number { + const activeBurstDelay = entry.reason === 'active' ? nextActiveBurstDelay(entry) : 0 + if (activeBurstDelay > 0) { + return activeBurstDelay + } + return isBudgetedQueueEntry(entry) ? nextBackgroundBudgetDelay() : 0 +} + +export function clearActiveRefreshBurstWindow(windowId: number): void { + const windowPrefix = `${windowId}::` + for (const scope of Array.from(activeStartsByScope.keys())) { + if (scope.startsWith(windowPrefix)) { + activeStartsByScope.delete(scope) + } + } +} diff --git a/src/main/github/pr-refresh-queue-drain.ts b/src/main/github/pr-refresh-queue-drain.ts new file mode 100644 index 00000000000..32bc6c506b8 --- /dev/null +++ b/src/main/github/pr-refresh-queue-drain.ts @@ -0,0 +1,188 @@ +import { isBackground, validateCandidate } from './pr-refresh-candidate-policy' +import { getGitHubRefreshRateLimitRetryAt, queryGitHubPullRequest } from './pr-refresh-github-query' +import { + activeRefreshOrder, + isBudgetedQueueEntry, + nextActiveBurstDelay, + nextBackgroundBudgetDelay, + noteActiveRefreshStart, + noteBackgroundRefreshStart, + refreshEntryPacingDelay +} from './pr-refresh-pacing' +import { + broadcastRefreshEvent, + isRefreshKeyVisible, + nextRefreshSequence, + nextVisibleErrorRetryAt, + noteManualRetryGate, + notifyRefreshOutcome, + recordRefreshQueueDiagnostic, + refreshQueue, + resetRefreshRetryState, + withErrorSchedule, + type QueueEntry +} from './pr-refresh-queue-state' +import { scheduleVisiblePRRefreshFollowUp } from './pr-refresh-visible-follow-up' + +let draining = false +let drainTimer: NodeJS.Timeout | undefined + +function nextQueuedWakeDelay(excludedKey: string): number | null { + const now = Date.now() + let nextDelay = Number.POSITIVE_INFINITY + for (const entry of refreshQueue.values()) { + if (entry.key === excludedKey) { + continue + } + const delay = entry.dueAt > now ? entry.dueAt - now : refreshEntryPacingDelay(entry) + nextDelay = Math.min(nextDelay, delay) + } + return Number.isFinite(nextDelay) ? Math.max(0, nextDelay) : null +} + +export function schedulePRRefreshDrain(delay = 0): void { + clearTimeout(drainTimer) + drainTimer = setTimeout(() => { + drainTimer = undefined + void drainPRRefreshQueue() + }, delay) +} + +function queuedEntriesByPriority(): QueueEntry[] { + const now = Date.now() + return Array.from(refreshQueue.values()).sort((a, b) => { + const aReady = a.dueAt <= now + const bReady = b.dueAt <= now + if (aReady && bReady) { + return b.priority - a.priority || activeRefreshOrder(a, b) || a.dueAt - b.dueAt + } + if (aReady !== bReady) { + return aReady ? -1 : 1 + } + return a.dueAt - b.dueAt || b.priority - a.priority + }) +} + +async function applyBackgroundExecutionGate(next: QueueEntry): Promise { + const retryAt = await getGitHubRefreshRateLimitRetryAt(next.candidate, true) + if (retryAt !== null) { + refreshQueue.set(next.key, { ...next, dueAt: retryAt }) + broadcastRefreshEvent({ + aliases: Array.from(next.aliases.values()), + reason: next.reason, + status: 'paused', + pausedUntil: retryAt, + skippedReason: 'rate-limit' + }) + schedulePRRefreshDrain(Math.max(1_000, retryAt - Date.now())) + return false + } + if (isBudgetedQueueEntry(next)) { + noteBackgroundRefreshStart() + } + if (next.reason === 'active') { + noteActiveRefreshStart(next) + } + return true +} + +async function runQueuedRefresh(next: QueueEntry): Promise { + refreshQueue.delete(next.key) + const aliases = Array.from(next.aliases.values()) + const skippedReason = validateCandidate(next.candidate) + if (skippedReason) { + recordRefreshQueueDiagnostic('skipped', next.reason, skippedReason) + broadcastRefreshEvent({ aliases, reason: next.reason, status: 'skipped', skippedReason }) + return + } + if (next.reason === 'visible' && !isRefreshKeyVisible(next.key)) { + resetRefreshRetryState(next.key) + broadcastRefreshEvent({ + aliases, + reason: next.reason, + status: 'skipped', + skippedReason: 'fresh' + }) + return + } + const requestSequence = nextRefreshSequence() + const requestStartedAt = Date.now() + broadcastRefreshEvent( + { aliases, reason: next.reason, status: 'in-flight', requestStartedAt }, + requestSequence + ) + if (isBackground(next.reason) && !(await applyBackgroundExecutionGate(next))) { + return + } + const outcome = await queryGitHubPullRequest(next.candidate) + let plannedRetryAt: number | undefined + let broadcastOutcome = outcome + if (outcome.kind === 'upstream-error' && isRefreshKeyVisible(next.key)) { + plannedRetryAt = nextVisibleErrorRetryAt(next.key) + broadcastOutcome = withErrorSchedule(outcome, plannedRetryAt) + } + notifyRefreshOutcome(next.candidate, outcome) + noteManualRetryGate(next.key, broadcastOutcome) + broadcastRefreshEvent( + { aliases, reason: next.reason, outcome: broadcastOutcome, requestStartedAt }, + requestSequence + ) + scheduleVisiblePRRefreshFollowUp( + next.key, + next.candidate, + outcome, + next.priority, + aliases, + schedulePRRefreshDrain, + next.windowId, + { plannedRetryAt } + ) +} + +async function drainPRRefreshQueue(): Promise { + if (draining) { + return + } + draining = true + try { + while (refreshQueue.size > 0) { + let next = queuedEntriesByPriority()[0] + const waitMs = next.dueAt - Date.now() + if (waitMs > 0) { + schedulePRRefreshDrain(waitMs) + return + } + let delay = refreshEntryPacingDelay(next) + if (delay > 0) { + const runnable = queuedEntriesByPriority().find( + (entry) => entry.dueAt <= Date.now() && refreshEntryPacingDelay(entry) === 0 + ) + if (runnable && runnable.key !== next.key) { + next = runnable + delay = 0 + } else { + if ( + next.reason === 'active' && + nextActiveBurstDelay(next) > 0 && + !next.activeDelayNotified + ) { + next.activeDelayNotified = true + broadcastRefreshEvent({ + aliases: Array.from(next.aliases.values()), + reason: next.reason, + status: 'queued' + }) + } + if (isBudgetedQueueEntry(next) && nextBackgroundBudgetDelay() > 0) { + recordRefreshQueueDiagnostic('background-pause', next.reason) + } + schedulePRRefreshDrain(Math.min(delay, nextQueuedWakeDelay(next.key) ?? delay)) + return + } + } + await runQueuedRefresh(next) + } + } finally { + draining = false + } +} diff --git a/src/main/github/pr-refresh-queue-state.ts b/src/main/github/pr-refresh-queue-state.ts new file mode 100644 index 00000000000..b65b20133c3 --- /dev/null +++ b/src/main/github/pr-refresh-queue-state.ts @@ -0,0 +1,255 @@ +import { webContents } from 'electron' +import type { + GitHubPRRefreshAlias, + GitHubPRRefreshCandidate, + GitHubPRRefreshEvent, + GitHubPRRefreshReason, + GitHubPRRefreshSkippedReason, + PRRefreshOutcome +} from '../../shared/github/pull-request-refresh-types' +import { lookupBackoffDelayMs } from '../source-control/hosted-review-refresh-pacing' +import { recordCoalescedCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' +import { sendToTrustedUIRenderer } from '../ipc/ui' + +const DIAGNOSTIC_BREADCRUMB_MIN_INTERVAL_MS = 30_000 + +export type QueueEntry = { + key: string + candidate: GitHubPRRefreshCandidate + aliases: Map + reason: GitHubPRRefreshReason + priority: number + dueAt: number + queuedAt: number + bypassBackgroundBudget?: boolean + activeDelayNotified?: boolean + windowId?: number +} + +export type PRRefreshOutcomeObserver = ( + candidate: GitHubPRRefreshCandidate, + outcome: PRRefreshOutcome +) => void + +export const refreshQueue = new Map() +export const visibleRefreshesByWindow = new Map }>() + +const errorBackoff = new Map() +const manualRetryGates = new Map() +let sequence = 0 +let queueOrder = 0 +let outcomeObserver: PRRefreshOutcomeObserver | null = null + +const diagnosticsCounters = { + enqueued: 0, + coalesced: 0, + skipped: 0, + backgroundPauses: 0 +} + +export function setRefreshOutcomeObserver(observer: PRRefreshOutcomeObserver | null): void { + outcomeObserver = observer +} + +export function notifyRefreshOutcome( + candidate: GitHubPRRefreshCandidate, + outcome: PRRefreshOutcome +): void { + outcomeObserver?.(candidate, outcome) +} + +export function nextRefreshSequence(): number { + sequence += 1 + return sequence +} + +export function nextRefreshQueueOrder(): number { + queueOrder += 1 + return queueOrder +} + +export function broadcastRefreshEvent( + event: Omit, + sequenceOverride?: number +): void { + const payload = { + ...event, + sequence: sequenceOverride ?? nextRefreshSequence() + } as GitHubPRRefreshEvent + sendToTrustedUIRenderer('gh:prRefreshEvent', payload) +} + +export function recordRefreshQueueDiagnostic( + event: 'enqueued' | 'coalesced' | 'skipped' | 'background-pause', + reason: GitHubPRRefreshReason, + skippedReason?: GitHubPRRefreshSkippedReason +): void { + if (event === 'enqueued') { + diagnosticsCounters.enqueued += 1 + } + if (event === 'coalesced') { + diagnosticsCounters.coalesced += 1 + } + if (event === 'skipped') { + diagnosticsCounters.skipped += 1 + } + if (event === 'background-pause') { + diagnosticsCounters.backgroundPauses += 1 + } + recordCoalescedCrashBreadcrumb({ + name: 'pr_refresh_queue', + coalesceKey: `pr-refresh-queue:${event}:${reason}:${skippedReason ?? ''}`, + minIntervalMs: DIAGNOSTIC_BREADCRUMB_MIN_INTERVAL_MS, + data: { + event, + reason, + ...(skippedReason ? { skippedReason } : {}), + enqueued: diagnosticsCounters.enqueued, + coalesced: diagnosticsCounters.coalesced, + skipped: diagnosticsCounters.skipped, + backgroundPauses: diagnosticsCounters.backgroundPauses + } + }) +} + +export function isRefreshKeyVisible(key: string): boolean { + const liveWindowIds = new Set( + webContents + .getAllWebContents() + .filter((contents) => !contents.isDestroyed()) + .map((contents) => contents.id) + ) + for (const windowId of Array.from(visibleRefreshesByWindow.keys())) { + if (!liveWindowIds.has(windowId)) { + visibleRefreshesByWindow.delete(windowId) + } + } + for (const visible of visibleRefreshesByWindow.values()) { + if (visible.keys.has(key)) { + return true + } + } + return false +} + +export function removeInvisibleVisibleRefreshes(): void { + for (const [key, entry] of refreshQueue) { + if (entry.reason === 'visible' && !isRefreshKeyVisible(key)) { + refreshQueue.delete(key) + resetRefreshRetryState(key) + broadcastRefreshEvent({ + aliases: Array.from(entry.aliases.values()), + reason: 'visible', + status: 'skipped', + skippedReason: 'fresh' + }) + } + } +} + +export function removeQueuedAliasForInvalidCandidate( + key: string, + alias: GitHubPRRefreshAlias +): void { + const existing = refreshQueue.get(key) + if (!existing) { + return + } + existing.aliases.delete(alias.cacheKey) + const replacementAlias = existing.aliases.values().next().value + if (!replacementAlias) { + refreshQueue.delete(key) + resetRefreshRetryState(key) + return + } + if (existing.candidate.cacheKey === alias.cacheKey) { + existing.candidate = { + ...existing.candidate, + cacheKey: replacementAlias.cacheKey, + branch: replacementAlias.branch, + worktreeId: replacementAlias.worktreeId, + currentHeadOid: replacementAlias.currentHeadOid ?? null, + isArchived: false, + isBare: false + } + } +} + +export function pruneWorktreeRefreshAliases(worktreeId: string): void { + for (const [key, entry] of refreshQueue) { + let removed = false + for (const [cacheKey, alias] of entry.aliases) { + if (alias.worktreeId === worktreeId) { + entry.aliases.delete(cacheKey) + removed = true + } + } + if (!removed) { + continue + } + if (entry.aliases.size === 0) { + refreshQueue.delete(key) + resetRefreshRetryState(key) + continue + } + if (entry.candidate.worktreeId === worktreeId) { + const replacementAlias = entry.aliases.values().next().value + if (replacementAlias) { + entry.candidate = { + ...entry.candidate, + cacheKey: replacementAlias.cacheKey, + branch: replacementAlias.branch, + worktreeId: replacementAlias.worktreeId, + currentHeadOid: replacementAlias.currentHeadOid ?? null + } + } + } + } +} + +export function noteManualRetryGate(key: string, outcome: PRRefreshOutcome): void { + if (outcome.kind === 'upstream-error' && outcome.retryDisabledUntil !== undefined) { + manualRetryGates.set(key, outcome.retryDisabledUntil) + } else { + manualRetryGates.delete(key) + } +} + +export function manualRetryGateUntil(key: string): number | undefined { + return manualRetryGates.get(key) +} + +export function resetRefreshRetryState(key: string): void { + errorBackoff.delete(key) + manualRetryGates.delete(key) +} + +export function nextVisibleErrorRetryAt(key: string): number { + const failures = (errorBackoff.get(key)?.failures ?? 0) + 1 + const retryAt = Date.now() + lookupBackoffDelayMs(failures) + errorBackoff.set(key, { failures, retryAt }) + return retryAt +} + +export function withErrorSchedule(outcome: PRRefreshOutcome, retryAt: number): PRRefreshOutcome { + if (outcome.kind !== 'upstream-error') { + return outcome + } + const cooldownUntil = outcome.retryDisabledUntil + return { + ...outcome, + nextAutoRetryAt: cooldownUntil !== undefined ? Math.max(retryAt, cooldownUntil) : retryAt + } +} + +export function visibleRefreshWindowCount(): number { + return visibleRefreshesByWindow.size +} + +export function refreshErrorBackoffCount(): number { + return errorBackoff.size +} + +export function refreshQueueAliasCount(key: string): number { + return refreshQueue.get(key)?.aliases.size ?? 0 +} diff --git a/src/main/github/pr-refresh-visible-follow-up.ts b/src/main/github/pr-refresh-visible-follow-up.ts new file mode 100644 index 00000000000..b7fa05c5041 --- /dev/null +++ b/src/main/github/pr-refresh-visible-follow-up.ts @@ -0,0 +1,97 @@ +import type { + GitHubPRRefreshAlias, + GitHubPRRefreshCandidate, + PRRefreshOutcome +} from '../../shared/github/pull-request-refresh-types' +import { + bypassesFreshnessDelay, + freshRetryAt, + isMergeabilityPendingOutcome, + visibleCandidateAfterOutcome +} from './pr-refresh-candidate-policy' +import { + isRefreshKeyVisible, + nextRefreshQueueOrder, + nextVisibleErrorRetryAt, + refreshQueue, + resetRefreshRetryState, + type QueueEntry +} from './pr-refresh-queue-state' + +type ScheduleDrain = (delay?: number) => void + +function setVisibleFollowUp(entry: QueueEntry): void { + const existing = refreshQueue.get(entry.key) + if (!existing) { + refreshQueue.set(entry.key, entry) + return + } + for (const alias of entry.aliases.values()) { + existing.aliases.set(alias.cacheKey, alias) + } + if ( + bypassesFreshnessDelay(existing.reason) || + existing.priority > entry.priority || + existing.dueAt <= entry.dueAt + ) { + return + } + refreshQueue.set(entry.key, { + ...entry, + aliases: existing.aliases + }) +} + +export function scheduleVisiblePRRefreshFollowUp( + key: string, + candidate: GitHubPRRefreshCandidate, + outcome: PRRefreshOutcome, + priority: number, + aliases: GitHubPRRefreshAlias[], + scheduleDrain: ScheduleDrain, + windowId?: number, + options?: { pendingMergeabilityDelayMs?: number; plannedRetryAt?: number } +): void { + if (!isRefreshKeyVisible(key)) { + resetRefreshRetryState(key) + return + } + if (outcome.kind === 'upstream-error') { + const retryAt = options?.plannedRetryAt ?? nextVisibleErrorRetryAt(key) + setVisibleFollowUp({ + key, + candidate, + aliases: new Map(aliases.map((alias) => [alias.cacheKey, alias])), + reason: 'visible', + priority, + dueAt: retryAt, + queuedAt: nextRefreshQueueOrder(), + windowId + }) + scheduleDrain(retryAt - Date.now()) + return + } + resetRefreshRetryState(key) + const followUpCandidate = visibleCandidateAfterOutcome(candidate, outcome) + const regularDueAt = freshRetryAt(followUpCandidate) ?? Date.now() + const pendingMergeabilityDueAt = + options?.pendingMergeabilityDelayMs !== undefined && isMergeabilityPendingOutcome(outcome) + ? outcome.fetchedAt + options.pendingMergeabilityDelayMs + : null + const dueAt = + pendingMergeabilityDueAt === null + ? regularDueAt + : Math.min(regularDueAt, pendingMergeabilityDueAt) + setVisibleFollowUp({ + key, + candidate: followUpCandidate, + aliases: new Map(aliases.map((alias) => [alias.cacheKey, alias])), + reason: 'visible', + priority, + dueAt, + queuedAt: nextRefreshQueueOrder(), + bypassBackgroundBudget: pendingMergeabilityDueAt !== null, + windowId + }) + scheduleDrain(Math.max(0, dueAt - Date.now())) +} diff --git a/src/main/github/project-view-host-auth.test.ts b/src/main/github/project-view-host-auth.test.ts index f243c06ed45..99a76f80ba2 100644 --- a/src/main/github/project-view-host-auth.test.ts +++ b/src/main/github/project-view-host-auth.test.ts @@ -23,6 +23,10 @@ vi.mock('../git/runner', () => ({ extractExecError: vi.fn(() => ({ stdout: '', stderr: '' })), ghExecFileAsync: ghExecFileAsyncMock })) +vi.mock('../git/github-cli-runner', () => ({ ghExecFileAsync: ghExecFileAsyncMock })) +vi.mock('../git/exec-error', () => ({ + extractExecError: vi.fn(() => ({ stdout: '', stderr: '' })) +})) vi.mock('./rate-limit', () => ({ rateLimitGuard: vi.fn(() => ({ blocked: false })), @@ -36,7 +40,8 @@ vi.mock('./github-enterprise-repository', () => ({ })) import { runGraphql, runRest } from './project-view/internals' -import { _resetProjectViewCachesForTests, resolveProjectRef } from './project-view' +import { _resetProjectViewCachesForTests } from './project-view/cache-state' +import { resolveProjectRef } from './project-view/project-reference' describe('project view host authentication boundary', () => { beforeEach(() => { diff --git a/src/main/github/project-view.test.ts b/src/main/github/project-view.test.ts index 48486ae4a9e..d7763af81f6 100644 --- a/src/main/github/project-view.test.ts +++ b/src/main/github/project-view.test.ts @@ -21,13 +21,11 @@ import { _markProjectViewParentFieldRetriedForTests, _markProjectViewParentFieldWarningLoggedForTests, _rememberProjectViewOwnerTypeForTests, - _resetProjectViewCachesForTests, - classifyProjectError, - isValidOwnerSlug, - isValidRepoSlug, - parseProjectPaste, - resolveProjectRef -} from './project-view' + _resetProjectViewCachesForTests +} from './project-view/cache-state' +import { classifyProjectError } from './project-view/project-error-classification' +import { isValidOwnerSlug, isValidRepoSlug } from './project-view/internals' +import { parseProjectPaste, resolveProjectRef } from './project-view/project-reference' describe('classifyProjectError', () => { it('classifies HTTP 404 as not_found', () => { diff --git a/src/main/github/project-view.ts b/src/main/github/project-view.ts index f9320a3b10e..79dca6ce8f8 100644 --- a/src/main/github/project-view.ts +++ b/src/main/github/project-view.ts @@ -1,1200 +1,27 @@ -/* eslint-disable max-lines -- Why: co-locate the ProjectV2 read path (normalize/retry/paste-parser/discovery) as one reviewable surface; plumbing lives in ./project-view/internals and mutations. */ -import { - acquire, - release, - extractExecError, - ghExecFileAsync, - repositoryRateLimitGuard, - noteRepositoryRateLimitSpend, - runGraphql, - isValidOwnerSlug, - assertSlug, - assertPositiveInt, - projectHostAuthenticationError, - projectGhExecOptions, - type GraphqlVars -} from './project-view/internals' -import { - classifyProjectError, - driftError, - errorsIndicateParentField, - rateLimitedError, - type GhGraphqlErrorShape -} from './project-view/project-error-classification' import type { - GitHubProjectField, - GitHubProjectFieldValue, - GitHubProjectIteration, - GitHubProjectLabel, - GitHubProjectOwnerType, - GitHubProjectRow, - GitHubProjectRowItemType, - GitHubProjectSingleSelectOption, - GitHubProjectSort, - GitHubProjectSummary, GitHubProjectTable, - GitHubProjectUser, - GitHubProjectView, GitHubProjectViewLayout, GitHubProjectViewSummary } from '../../shared/github/project-types' import type { GetProjectViewTableResult, - GitHubProjectViewError, - ListAccessibleProjectsResult, - ListProjectViewsResult, - ResolveProjectRefResult + ListProjectViewsResult } from '../../shared/github/project-result-types' import type { GetProjectViewTableArgs, - ListAccessibleProjectsArgs, - ListProjectViewsArgs, - ResolveProjectRefArgs + ListProjectViewsArgs } from '../../shared/github/project-request-types' -import { - GITHUB_PROJECT_REF_INPUT_TOO_LARGE_ERROR, - isGitHubProjectRefInputTooLarge -} from '../../shared/github/project-ref-input' import { githubProjectHost } from '../../shared/github/project-identity' - -// Re-export the public API so existing `./project-view` call sites keep working; the split is internal-only. -export { isValidOwnerSlug, isValidRepoSlug } from './project-view/internals' -export { classifyProjectError } from './project-view/project-error-classification' -export { - updateProjectItemFieldValue, - clearProjectItemFieldValue, - updateIssueBySlug, - updatePullRequestBySlug, - addIssueCommentBySlug, - updateIssueCommentBySlug, - deleteIssueCommentBySlug, - listLabelsBySlug, - listAssignableUsersBySlug, - listIssueTypesBySlug, - updateIssueTypeBySlug, - getWorkItemDetailsBySlug -} from './project-view/mutations' - -// ─── Constants ───────────────────────────────────────────────────────── - -// Why: defaults deliberately shrunk to cut quota spend in discovery — the org loop dominates and produced the HTTP 504; overflow owners can paste a URL. -const ITEM_PAGE_SIZE = 100 -const MAX_ITEMS = 500 -const VIEWS_PAGE_SIZE = 20 -const FIELDS_PAGE_SIZE = 50 -const DISCOVERY_PROJECTS_PER_OWNER = 40 -const DISCOVERY_MAX_ORGS = 20 -const DISCOVERY_ORG_PAGE_SIZE = 20 -const DISCOVERY_PROJECTS_PER_ORG = 20 -const FIELD_VALUES_PAGE_SIZE = 100 -export const PROJECT_VIEW_OWNER_CACHE_MAX_ENTRIES = 512 - -// ─── Module-scope caches (reset on HMR — intentional) ────────────────── - -// Why: owners are user-controlled over a long session; bound cache entries to avoid unbounded retention while keeping the hot-owner fast path. -function rememberProjectViewCacheEntry( - cache: Map, - key: K, - value: V, - maxEntries = PROJECT_VIEW_OWNER_CACHE_MAX_ENTRIES -): void { - if (cache.has(key)) { - cache.delete(key) - } - cache.set(key, value) - while (cache.size > maxEntries) { - const oldest = cache.keys().next() - if (oldest.done) { - break - } - cache.delete(oldest.value) - } -} - -function getProjectViewCacheEntry(cache: Map, key: K): V | undefined { - if (!cache.has(key)) { - return undefined - } - const value = cache.get(key) as V - rememberProjectViewCacheEntry(cache, key, value) - return value -} - -// Why: plain module locals so HMR code swaps re-run capability probes instead of carrying a stale "unsupported" flag. -const ownerTypeCache = new Map() -// Why: keyed per owner (not a process-global flag) so one owner's capability gap doesn't poison others that DO support Issue.parent (bug-scan finding 2). -const parentFieldRetriedByOwner = new Map() -const parentFieldWarningLoggedByOwner = new Map() -// Why: in-flight promise per owner so concurrent fetchAllItems callers share one probe instead of each racing a duplicate first-page probe. -const parentFieldProbeInFlight = new Map>() - -// Why: GHES owners are a separate namespace and capability surface from -// github.com owners with the same login — scope cache keys by host so one -// host's probe result can't leak into another. Normalize github.com so -// host-less callers share the same probe state as explicitly pinned calls. -function ownerScopeKey(owner: string, ownerType: GitHubProjectOwnerType, host?: string): string { - const base = `${owner}\u0000${ownerType}` - return `${base}\u0000${githubProjectHost(host)}` -} - -function ownerTypeCacheKey(owner: string, host?: string): string { - return `${owner}\u0000${githubProjectHost(host)}` -} - -function rememberOwnerType( - owner: string, - ownerType: GitHubProjectOwnerType | null, - host?: string -): void { - rememberProjectViewCacheEntry(ownerTypeCache, ownerTypeCacheKey(owner, host), ownerType) -} - -function getCachedOwnerType( - owner: string, - host?: string -): GitHubProjectOwnerType | null | undefined { - return getProjectViewCacheEntry(ownerTypeCache, ownerTypeCacheKey(owner, host)) -} - -function markParentFieldRetried(scopeKey: string): void { - rememberProjectViewCacheEntry(parentFieldRetriedByOwner, scopeKey, true) -} - -function hasParentFieldRetried(scopeKey: string): boolean { - return getProjectViewCacheEntry(parentFieldRetriedByOwner, scopeKey) === true -} - -function markParentFieldWarningLogged(scopeKey: string): void { - rememberProjectViewCacheEntry(parentFieldWarningLoggedByOwner, scopeKey, true) -} - -function hasParentFieldWarningLogged(scopeKey: string): boolean { - return getProjectViewCacheEntry(parentFieldWarningLoggedByOwner, scopeKey) === true -} - -export function _resetProjectViewCachesForTests(): void { - ownerTypeCache.clear() - parentFieldRetriedByOwner.clear() - parentFieldWarningLoggedByOwner.clear() - parentFieldProbeInFlight.clear() -} - -export function _getProjectViewCacheSizesForTests(): { - ownerTypes: number - parentFieldRetries: number - parentFieldWarnings: number - parentFieldProbes: number -} { - return { - ownerTypes: ownerTypeCache.size, - parentFieldRetries: parentFieldRetriedByOwner.size, - parentFieldWarnings: parentFieldWarningLoggedByOwner.size, - parentFieldProbes: parentFieldProbeInFlight.size - } -} - -/** @internal - exposed for cache-bound tests only. */ -export function _rememberProjectViewOwnerTypeForTests( - owner: string, - ownerType: GitHubProjectOwnerType | null, - host?: string -): void { - rememberOwnerType(owner, ownerType, host) -} - -/** @internal - exposed for cache-bound tests only. */ -export function _getProjectViewOwnerTypeForTests( - owner: string, - host?: string -): GitHubProjectOwnerType | null | undefined { - return getCachedOwnerType(owner, host) -} - -/** @internal - exposed for cache-bound tests only. */ -export function _markProjectViewParentFieldRetriedForTests(scopeKey: string): void { - markParentFieldRetried(scopeKey) -} - -/** @internal - exposed for cache-bound tests only. */ -export function _hasProjectViewParentFieldRetriedForTests(scopeKey: string): boolean { - return hasParentFieldRetried(scopeKey) -} - -/** @internal - exposed for cache-bound tests only. */ -export function _markProjectViewParentFieldWarningLoggedForTests(scopeKey: string): void { - markParentFieldWarningLogged(scopeKey) -} - -/** @internal - exposed for cache-bound tests only. */ -export function _hasProjectViewParentFieldWarningLoggedForTests(scopeKey: string): boolean { - return hasParentFieldWarningLogged(scopeKey) -} - -// ─── Normalizers ─────────────────────────────────────────────────────── - -type RawProjectV2Field = { - __typename?: string - id?: string - name?: string - dataType?: string - options?: { id?: string; name?: string; color?: string }[] - configuration?: { - iterations?: { id?: string; title?: string; startDate?: string; duration?: number }[] - completedIterations?: { - id?: string - title?: string - startDate?: string - duration?: number - }[] - } -} - -export function normalizeField( - raw: RawProjectV2Field | null | undefined -): GitHubProjectField | null { - if (!raw || typeof raw.id !== 'string' || typeof raw.name !== 'string') { - return null - } - const dataType = raw.dataType ?? raw.__typename ?? '' - if (raw.__typename === 'ProjectV2SingleSelectField' || dataType === 'SINGLE_SELECT') { - const options: GitHubProjectSingleSelectOption[] = (raw.options ?? []) - .map((o) => - typeof o.id === 'string' && typeof o.name === 'string' - ? { id: o.id, name: o.name, color: o.color ?? '' } - : null - ) - .filter((o): o is GitHubProjectSingleSelectOption => o !== null) - return { kind: 'single-select', id: raw.id, name: raw.name, dataType: 'SINGLE_SELECT', options } - } - if (raw.__typename === 'ProjectV2IterationField' || dataType === 'ITERATION') { - const cfg = raw.configuration ?? {} - const iterations: GitHubProjectIteration[] = [] - for (const it of cfg.completedIterations ?? []) { - if (typeof it.id === 'string' && typeof it.title === 'string') { - iterations.push({ - id: it.id, - title: it.title, - startDate: it.startDate ?? '', - duration: typeof it.duration === 'number' ? it.duration : 0, - completed: true - }) - } - } - for (const it of cfg.iterations ?? []) { - if (typeof it.id === 'string' && typeof it.title === 'string') { - iterations.push({ - id: it.id, - title: it.title, - startDate: it.startDate ?? '', - duration: typeof it.duration === 'number' ? it.duration : 0, - completed: false - }) - } - } - return { kind: 'iteration', id: raw.id, name: raw.name, dataType: 'ITERATION', iterations } - } - return { kind: 'field', id: raw.id, name: raw.name, dataType } -} - -type RawUser = { - login?: string - name?: string | null - avatarUrl?: string | null -} - -function normalizeUser(raw: RawUser | null | undefined): GitHubProjectUser | null { - if (!raw || typeof raw.login !== 'string') { - return null - } - return { - login: raw.login, - name: raw.name ?? null, - avatarUrl: raw.avatarUrl ?? null - } -} - -type RawLabel = { name?: string; color?: string } - -function normalizeLabel(raw: RawLabel | null | undefined): GitHubProjectLabel | null { - if (!raw || typeof raw.name !== 'string') { - return null - } - return { name: raw.name, color: raw.color ?? '' } -} - -type RawFieldValue = { - __typename?: string - field?: RawProjectV2Field - name?: string - color?: string - optionId?: string - title?: string - startDate?: string - duration?: number - iterationId?: string - text?: string - number?: number - date?: string - labels?: { nodes?: RawLabel[] } - users?: { nodes?: RawUser[] } -} - -export function normalizeFieldValue( - raw: RawFieldValue | null | undefined -): GitHubProjectFieldValue | null { - if (!raw || !raw.field || typeof raw.field.id !== 'string') { - return null - } - const fieldId = raw.field.id - switch (raw.__typename) { - case 'ProjectV2ItemFieldSingleSelectValue': - if (typeof raw.optionId !== 'string') { - return null - } - return { - kind: 'single-select', - fieldId, - optionId: raw.optionId, - name: raw.name ?? '', - color: raw.color ?? '' - } - case 'ProjectV2ItemFieldIterationValue': - if (typeof raw.iterationId !== 'string') { - return null - } - return { - kind: 'iteration', - fieldId, - iterationId: raw.iterationId, - title: raw.title ?? '', - startDate: raw.startDate ?? '', - duration: typeof raw.duration === 'number' ? raw.duration : 0 - } - case 'ProjectV2ItemFieldTextValue': - return { kind: 'text', fieldId, text: raw.text ?? '' } - case 'ProjectV2ItemFieldNumberValue': - if (typeof raw.number !== 'number') { - return null - } - return { kind: 'number', fieldId, number: raw.number } - case 'ProjectV2ItemFieldDateValue': - return { kind: 'date', fieldId, date: raw.date ?? '' } - case 'ProjectV2ItemFieldLabelValue': { - const labels = (raw.labels?.nodes ?? []) - .map(normalizeLabel) - .filter((l): l is GitHubProjectLabel => l !== null) - return { kind: 'labels', fieldId, labels } - } - case 'ProjectV2ItemFieldUserValue': { - const users = (raw.users?.nodes ?? []) - .map(normalizeUser) - .filter((u): u is GitHubProjectUser => u !== null) - return { kind: 'users', fieldId, users } - } - case undefined: - default: - // Unknown __typename → forward-compat: drop silently, don't classify as drift (see design §Error Handling). - return null - } -} - -type RawContent = { - __typename?: string - id?: string - number?: number - title?: string - body?: string - url?: string - state?: string - stateReason?: string | null - isDraft?: boolean - repository?: { nameWithOwner?: string } - assignees?: { nodes?: RawUser[] } - labels?: { nodes?: RawLabel[] } - parent?: { number?: number; title?: string; url?: string } | null - issueType?: { - id?: string - name?: string - color?: string | null - description?: string | null - } | null -} - -type RawItem = { - id?: string - type?: string - updatedAt?: string - content?: RawContent | null - fieldValues?: { - nodes?: RawFieldValue[] - pageInfo?: { hasNextPage?: boolean } - } -} - -type NormalizedItemOutcome = - | { ok: true; row: GitHubProjectRow } - | { ok: false; drift: GitHubProjectViewError } - -function mapItemType(raw: string | undefined, hasContent: boolean): GitHubProjectRowItemType { - if (raw === 'ISSUE') { - return 'ISSUE' - } - if (raw === 'PULL_REQUEST') { - return 'PULL_REQUEST' - } - if (raw === 'DRAFT_ISSUE') { - return 'DRAFT_ISSUE' - } - if (raw === 'REDACTED' || !hasContent) { - return 'REDACTED' - } - // Unknown item type with content — treat as redacted rather than dropping. - return 'REDACTED' -} - -export function normalizeItem(raw: RawItem, position: number): NormalizedItemOutcome { - if (!raw || typeof raw.id !== 'string') { - return { - ok: false, - drift: driftError('item missing id', { path: ['items', 'nodes', position, 'id'] }) - } - } - if (raw.fieldValues?.pageInfo?.hasNextPage === true) { - return { - ok: false, - drift: driftError('item field values exceeded single page', { - path: ['items', 'nodes', position, 'fieldValues', 'pageInfo', 'hasNextPage'] - }) - } - } - const itemType = mapItemType(raw.type, raw.content !== null && raw.content !== undefined) - const content = raw.content ?? null - const assignees = (content?.assignees?.nodes ?? []) - .map(normalizeUser) - .filter((u): u is GitHubProjectUser => u !== null) - const labels = (content?.labels?.nodes ?? []) - .map(normalizeLabel) - .filter((l): l is GitHubProjectLabel => l !== null) - const parentIssue = - content?.parent && - typeof content.parent.number === 'number' && - typeof content.parent.title === 'string' && - typeof content.parent.url === 'string' - ? { number: content.parent.number, title: content.parent.title, url: content.parent.url } - : null - const issueType = - content?.issueType && - typeof content.issueType.id === 'string' && - typeof content.issueType.name === 'string' - ? { - id: content.issueType.id, - name: content.issueType.name, - color: typeof content.issueType.color === 'string' ? content.issueType.color : null, - description: - typeof content.issueType.description === 'string' ? content.issueType.description : null - } - : null - const fieldValuesByFieldId: Record = {} - for (const fv of raw.fieldValues?.nodes ?? []) { - const normalized = normalizeFieldValue(fv) - if (normalized) { - fieldValuesByFieldId[normalized.fieldId] = normalized - } - } - const title = - itemType === 'REDACTED' - ? 'Restricted item' - : typeof content?.title === 'string' - ? content.title - : '' - const row: GitHubProjectRow = { - id: raw.id, - itemType, - content: { - number: typeof content?.number === 'number' ? content.number : null, - title, - body: typeof content?.body === 'string' ? content.body : null, - url: typeof content?.url === 'string' ? content.url : null, - state: typeof content?.state === 'string' ? content.state : null, - stateReason: typeof content?.stateReason === 'string' ? content.stateReason : null, - isDraft: typeof content?.isDraft === 'boolean' ? content.isDraft : null, - repository: - typeof content?.repository?.nameWithOwner === 'string' - ? content.repository.nameWithOwner - : null, - assignees, - labels, - parentIssue, - issueType - }, - fieldValuesByFieldId, - updatedAt: typeof raw.updatedAt === 'string' ? raw.updatedAt : '', - position - } - return { ok: true, row } -} - -// ─── GraphQL query fragments ─────────────────────────────────────────── - -const FIELD_CONFIG_FRAGMENT = ` -fragment FieldConfig on ProjectV2FieldConfiguration { - __typename - ... on ProjectV2Field { id name dataType } - ... on ProjectV2SingleSelectField { - id - name - dataType - options { id name color } - } - ... on ProjectV2IterationField { - id - name - dataType - configuration { - iterations { id title startDate duration } - completedIterations { id title startDate duration } - } - } -} -` - -function itemContentSelection(includeParent: boolean): string { - const parentFrag = includeParent ? 'parent { number title url }' : '' - return ` - __typename - ... on Issue { - id - number - title - url - state - stateReason - repository { nameWithOwner } - assignees(first:5) { nodes { login name avatarUrl } } - labels(first:10) { nodes { name color } } - issueType { id name color description } - ${parentFrag} - } - ... on PullRequest { - id - number - title - url - state - isDraft - repository { nameWithOwner } - assignees(first:5) { nodes { login name avatarUrl } } - labels(first:10) { nodes { name color } } - } - ... on DraftIssue { id title body } - ` -} - -const FIELD_VALUES_SELECTION = ` - fieldValues(first:${FIELD_VALUES_PAGE_SIZE}) { - pageInfo { hasNextPage } - nodes { - __typename - ... on ProjectV2ItemFieldSingleSelectValue { field { ...FieldConfig } name color optionId } - ... on ProjectV2ItemFieldIterationValue { field { ...FieldConfig } title startDate duration iterationId } - ... on ProjectV2ItemFieldTextValue { field { ...FieldConfig } text } - ... on ProjectV2ItemFieldNumberValue { field { ...FieldConfig } number } - ... on ProjectV2ItemFieldDateValue { field { ...FieldConfig } date } - ... on ProjectV2ItemFieldLabelValue { field { ...FieldConfig } labels(first:10) { nodes { name color } } } - ... on ProjectV2ItemFieldUserValue { field { ...FieldConfig } users(first:5) { nodes { login name avatarUrl } } } - } - } -` - -// ─── Project config fetch (views + fields, paginated) ────────────────── - -type RawProjectConfig = { - id?: string - title?: string - url?: string - views?: { - pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } - nodes?: (RawProjectView | null)[] - } -} - -type RawProjectView = { - id?: string - number?: number - name?: string - layout?: string - filter?: string | null - fields?: { - pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } - nodes?: (RawProjectV2Field | null)[] - } - groupByFields?: { nodes?: (RawProjectV2Field | null)[] } - sortByFields?: { - nodes?: ({ direction?: string; field?: RawProjectV2Field | null } | null)[] - } -} - -function ownerQueryRoot(ownerType: GitHubProjectOwnerType): string { - return ownerType === 'organization' ? 'organization' : 'user' -} - -async function fetchProjectViewsPage(args: { - owner: string - ownerType: GitHubProjectOwnerType - projectNumber: number - host?: string - after: string | null -}): Promise< - | { - ok: true - project: { id: string; title: string; url: string } - views: RawProjectView[] - hasNextPage: boolean - endCursor: string | null - } - | { ok: false; error: GitHubProjectViewError } -> { - const root = ownerQueryRoot(args.ownerType) - const afterArg = args.after ? `, after: $after` : '' - const afterVar = args.after ? `$after:String!, ` : '' - const query = ` - query(${afterVar}$owner:String!, $num:Int!) { - ${root}(login:$owner) { - projectV2(number:$num) { - id title url - views(first:${VIEWS_PAGE_SIZE}${afterArg}) { - pageInfo { hasNextPage endCursor } - nodes { - id number name layout filter - fields(first:${FIELDS_PAGE_SIZE}) { - pageInfo { hasNextPage endCursor } - nodes { ...FieldConfig } - } - groupByFields(first:10) { nodes { ...FieldConfig } } - sortByFields(first:10) { - nodes { direction field { ...FieldConfig } } - } - } - } - } - } - } - ${FIELD_CONFIG_FRAGMENT} - ` - const vars: GraphqlVars = { owner: args.owner, num: args.projectNumber } - if (args.after) { - vars.after = args.after - } - const res = await runGraphql>( - query, - vars, - projectGhExecOptions(args.host) - ) - if (!res.ok) { - return res - } - const top = res.data[root] - const project = top?.projectV2 ?? null - if (!project || typeof project.id !== 'string') { - return { ok: false, error: { type: 'not_found', message: 'Project not found.' } } - } - const pageInfo = project.views?.pageInfo - const views = (project.views?.nodes ?? []).filter((v): v is RawProjectView => v !== null) - return { - ok: true, - project: { id: project.id, title: project.title ?? '', url: project.url ?? '' }, - views, - hasNextPage: pageInfo?.hasNextPage === true, - endCursor: pageInfo?.endCursor ?? null - } -} - -async function fetchViewFieldsContinuation( - viewId: string, - after: string, - host?: string -): Promise< - { ok: true; fields: RawProjectV2Field[] } | { ok: false; error: GitHubProjectViewError } -> { - // Why: address the view directly via node(id:) instead of re-walking all views each page — one round-trip per field page. - const query = ` - query($after:String!, $viewId:ID!) { - node(id:$viewId) { - ... on ProjectV2View { - id - fields(first:${FIELDS_PAGE_SIZE}, after:$after) { - pageInfo { hasNextPage endCursor } - nodes { ...FieldConfig } - } - } - } - } - ${FIELD_CONFIG_FRAGMENT} - ` - const collected: RawProjectV2Field[] = [] - let cursor: string | null = after - while (cursor !== null) { - const res = await runGraphql<{ - node?: { - id?: string - fields?: { - pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } - nodes?: (RawProjectV2Field | null)[] - } - } | null - }>(query, { viewId, after: cursor }, projectGhExecOptions(host)) - if (!res.ok) { - return res - } - const view = res.data.node ?? null - if (!view) { - return { ok: false, error: driftError('view disappeared during field pagination') } - } - const nodes = (view.fields?.nodes ?? []).filter((f): f is RawProjectV2Field => f !== null) - collected.push(...nodes) - const pi = view.fields?.pageInfo - cursor = pi?.hasNextPage === true && typeof pi.endCursor === 'string' ? pi.endCursor : null - } - return { ok: true, fields: collected } -} - -function finalizeView( - raw: RawProjectView, - extraFields: RawProjectV2Field[] -): { ok: true; view: GitHubProjectView } | { ok: false; drift: GitHubProjectViewError } { - if (typeof raw.id !== 'string' || typeof raw.layout !== 'string') { - return { ok: false, drift: driftError('view missing id or layout') } - } - const layout = raw.layout as GitHubProjectViewLayout - const fields: GitHubProjectField[] = [] - const all = [...(raw.fields?.nodes ?? []), ...extraFields.map((f) => f as RawProjectV2Field)] - for (const f of all) { - const n = normalizeField(f) - if (n) { - fields.push(n) - } - } - const groupByFields: GitHubProjectField[] = [] - for (const f of raw.groupByFields?.nodes ?? []) { - const n = normalizeField(f) - if (n) { - groupByFields.push(n) - } - } - const sortByFields: GitHubProjectSort[] = [] - for (const s of raw.sortByFields?.nodes ?? []) { - if (!s || (s.direction !== 'ASC' && s.direction !== 'DESC')) { - continue - } - const n = normalizeField(s.field) - if (n) { - sortByFields.push({ direction: s.direction, field: n }) - } - } - return { - ok: true, - view: { - id: raw.id, - number: typeof raw.number === 'number' ? raw.number : 0, - name: typeof raw.name === 'string' ? raw.name : '', - layout, - // Why: `ProjectV2View.filter` is nullable — normalize to ''. - filter: typeof raw.filter === 'string' ? raw.filter : '', - fields, - groupByFields, - sortByFields - } - } -} - -// ─── View selection ─────────────────────────────────────────────────── - -function matchesSelector( - raw: RawProjectView, - sel: { viewId?: string; viewNumber?: number; viewName?: string } -): 'none' | 'id' | 'number' | 'name' | 'default' { - if (sel.viewId && raw.id === sel.viewId) { - return 'id' - } - if (sel.viewNumber !== undefined && raw.number === sel.viewNumber) { - return 'number' - } - if (sel.viewName && raw.name === sel.viewName) { - return 'name' - } - if ( - sel.viewId === undefined && - sel.viewNumber === undefined && - sel.viewName === undefined && - raw.layout === 'TABLE_LAYOUT' - ) { - return 'default' - } - return 'none' -} - -// ─── Items fetch (paginated) ────────────────────────────────────────── - -type RawItemsPage = { - totalCount?: number - pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } - nodes?: (RawItem | null)[] -} - -// Why: unlike runGraphql, return the raw GraphQL error envelope so the parent-field retry decision can re-inspect it. -async function fetchItemsPageWithRaw(args: { - owner: string - ownerType: GitHubProjectOwnerType - projectNumber: number - query: string - first: number - after: string | null - includeParent: boolean - host?: string -}): Promise< - | { ok: true; page: RawItemsPage } - | { - ok: false - error: GitHubProjectViewError - rawErrors: GhGraphqlErrorShape[] - stderr: string - } -> { - const authError = await projectHostAuthenticationError(args.host) - if (authError) { - return { ok: false, error: authError, rawErrors: [], stderr: '' } - } - const root = ownerQueryRoot(args.ownerType) - const afterArg = args.after ? `, after: $after` : '' - const afterVar = args.after ? `$after:String!, ` : '' - const query = ` - query(${afterVar}$owner:String!, $num:Int!, $q:String!, $first:Int!) { - ${root}(login:$owner) { - projectV2(number:$num) { - items(first:$first${afterArg}, query:$q, orderBy:{ field: POSITION, direction: ASC }) { - totalCount - pageInfo { hasNextPage endCursor } - nodes { - id - type - updatedAt - content { ${itemContentSelection(args.includeParent)} } - ${FIELD_VALUES_SELECTION} - } - } - } - } - } - ${FIELD_CONFIG_FRAGMENT} - ` - const argsArr: string[] = ['api', 'graphql', '-f', `query=${query}`] - argsArr.push('-f', `owner=${args.owner}`) - argsArr.push('-F', `num=${args.projectNumber}`) - argsArr.push('-f', `q=${args.query}`) - argsArr.push('-F', `first=${args.first}`) - if (args.after) { - argsArr.push('-f', `after=${args.after}`) - } - - // Why: GHES traffic runs against its own quota — only github.com requests - // consult/debit the shared snapshot. - const guard = repositoryRateLimitGuard(args, 'graphql') - if (guard.blocked) { - return { - ok: false, - error: rateLimitedError(guard), - rawErrors: [], - stderr: '' - } - } - await acquire() - noteRepositoryRateLimitSpend(args, 'graphql') - try { - let stdout = '' - let stderr = '' - let execFailed = false - try { - const r = await ghExecFileAsync(argsArr, { - encoding: 'utf-8', - ...projectGhExecOptions(args.host) - }) - stdout = r.stdout - stderr = r.stderr - } catch (err) { - const extracted = extractExecError(err) - stderr = extracted.stderr - stdout = extracted.stdout - execFailed = true - } - let parsed: { data?: Record; errors?: GhGraphqlErrorShape[] } = {} - try { - parsed = JSON.parse(stdout) - } catch { - // Why: gh exited non-zero with unparseable stdout; classify against stderr so callers see the real cause, not a synthesized drift/not-found. - if (execFailed) { - return { - ok: false, - error: classifyProjectError(stderr, stdout, args.host), - rawErrors: [], - stderr - } - } - return { - ok: false, - error: driftError('failed to parse items response'), - rawErrors: [], - stderr - } - } - // Why: gh rejected but stdout parsed; fall through to parsed.errors below, else surface the stderr classification rather than not_found. - if (execFailed && (!parsed.errors || parsed.errors.length === 0) && !parsed.data) { - return { - ok: false, - error: classifyProjectError(stderr, stdout, args.host), - rawErrors: [], - stderr - } - } - if (parsed.errors && parsed.errors.length > 0) { - return { - ok: false, - error: classifyProjectError(stderr, stdout, args.host), - rawErrors: parsed.errors, - stderr - } - } - const top = parsed.data?.[root] as { projectV2?: { items?: RawItemsPage } | null } | undefined - const page = top?.projectV2?.items - if (!page) { - return { - ok: false, - error: { type: 'not_found', message: 'Project or view not found.' }, - rawErrors: [], - stderr - } - } - return { ok: true, page } - } finally { - release() - } -} - -async function fetchAllItems(args: { - owner: string - ownerType: GitHubProjectOwnerType - projectNumber: number - query: string - host?: string -}): Promise< - | { ok: true; rows: GitHubProjectRow[]; totalCount: number; parentFieldDropped: boolean } - | { ok: false; error: GitHubProjectViewError; totalCount?: number } -> { - // Why: isolate missing Issue.parent capability by owner, type, and host. - const scopeKey = ownerScopeKey(args.owner, args.ownerType, args.host) - // Why: await the same-scope probe, then re-read state because it may have changed. - const inFlight = parentFieldProbeInFlight.get(scopeKey) - if (inFlight) { - await inFlight.catch(() => {}) - } - let includeParent = !hasParentFieldRetried(scopeKey) - let parentFieldDropped = !includeParent - // Single-flight the with-parent probe per owner; assign the in-flight promise synchronously (no await between get() and set()) so callers share one probe. - let first: Awaited> - let probePromise: Promise>> | null = null - if (includeParent && !parentFieldProbeInFlight.has(scopeKey)) { - let resolveProbe: () => void = () => {} - const probe = new Promise((resolve) => { - resolveProbe = resolve - }) - parentFieldProbeInFlight.set(scopeKey, probe) - probePromise = (async () => { - try { - const result = await fetchItemsPageWithRaw({ - owner: args.owner, - ownerType: args.ownerType, - projectNumber: args.projectNumber, - query: args.query, - first: ITEM_PAGE_SIZE, - after: null, - includeParent: true, - host: args.host - }) - // Why: set the retried flag BEFORE resolving/clearing the probe so siblings awoken on inFlight.catch() see it and don't fire duplicate with-parent probes. - if (!result.ok && errorsIndicateParentField(result.rawErrors, result.stderr)) { - markParentFieldRetried(scopeKey) - } - return result - } finally { - resolveProbe() - parentFieldProbeInFlight.delete(scopeKey) - } - })() - first = await probePromise - } else { - first = await fetchItemsPageWithRaw({ - owner: args.owner, - ownerType: args.ownerType, - projectNumber: args.projectNumber, - query: args.query, - first: ITEM_PAGE_SIZE, - after: null, - includeParent, - host: args.host - }) - } - if (!first.ok && includeParent && errorsIndicateParentField(first.rawErrors, first.stderr)) { - // Retry the whole table without parent; mark this owner retried so later fetches skip the probe (other owners unaffected). - markParentFieldRetried(scopeKey) - includeParent = false - parentFieldDropped = true - if (!hasParentFieldWarningLogged(scopeKey)) { - console.warn( - `[project-view] Issue.parent is not available for ${args.owner} on this token — retrying without the parent selection.` - ) - markParentFieldWarningLogged(scopeKey) - } - first = await fetchItemsPageWithRaw({ - owner: args.owner, - ownerType: args.ownerType, - projectNumber: args.projectNumber, - query: args.query, - first: ITEM_PAGE_SIZE, - after: null, - includeParent: false, - host: args.host - }) - } - if (!first.ok) { - return { ok: false, error: first.error } - } - - // Drift guards - if (first.page.totalCount === undefined || first.page.totalCount === null) { - return { ok: false, error: driftError('items.totalCount missing') } - } - const totalCount = first.page.totalCount - if (first.page.pageInfo?.hasNextPage === undefined) { - return { ok: false, error: driftError('items.pageInfo.hasNextPage missing'), totalCount } - } - if (!Array.isArray(first.page.nodes)) { - return { ok: false, error: driftError('items.nodes missing'), totalCount } - } - - // Size cap - if (totalCount > MAX_ITEMS) { - return { - ok: false, - error: { type: 'too_large', message: `View has ${totalCount} items.` }, - totalCount - } - } - - const rows: GitHubProjectRow[] = [] - let position = 0 - const appendNodes = (nodes: (RawItem | null)[]): GitHubProjectViewError | null => { - for (const n of nodes) { - if (!n) { - continue - } - const norm = normalizeItem(n, position) - if (!norm.ok) { - return norm.drift - } - rows.push(norm.row) - position++ - } - return null - } - const e1 = appendNodes(first.page.nodes) - if (e1) { - return { ok: false, error: e1, totalCount } - } - - // Paginate - let hasNext = first.page.pageInfo.hasNextPage === true - let cursor: string | null | undefined = first.page.pageInfo.endCursor - if (hasNext && typeof cursor !== 'string') { - return { - ok: false, - error: driftError('items.pageInfo.endCursor missing with hasNextPage=true'), - totalCount - } - } - while (hasNext) { - const next = await fetchItemsPageWithRaw({ - owner: args.owner, - ownerType: args.ownerType, - projectNumber: args.projectNumber, - query: args.query, - first: ITEM_PAGE_SIZE, - after: cursor as string, - includeParent, - host: args.host - }) - if (!next.ok) { - return { ok: false, error: next.error, totalCount } - } - if (!Array.isArray(next.page.nodes)) { - return { ok: false, error: driftError('items.nodes missing on follow page'), totalCount } - } - if (next.page.pageInfo?.hasNextPage === undefined) { - return { - ok: false, - error: driftError('items.pageInfo.hasNextPage missing on follow page'), - totalCount - } - } - const e2 = appendNodes(next.page.nodes) - if (e2) { - return { ok: false, error: e2, totalCount } - } - hasNext = next.page.pageInfo.hasNextPage === true - cursor = next.page.pageInfo.endCursor - if (hasNext && typeof cursor !== 'string') { - return { - ok: false, - error: driftError('items.pageInfo.endCursor missing with hasNextPage=true'), - totalCount - } - } - } - return { ok: true, rows, totalCount, parentFieldDropped } -} - -// ─── Cheap count-only query (for unsupported_layout) ────────────────── - -async function fetchItemsCountOnly(args: { - owner: string - ownerType: GitHubProjectOwnerType - projectNumber: number - query: string - host?: string -}): Promise { - const root = ownerQueryRoot(args.ownerType) - const query = ` - query($owner:String!, $num:Int!, $q:String!) { - ${root}(login:$owner) { - projectV2(number:$num) { - items(first:1, query:$q) { totalCount } - } - } - } - ` - const res = await runGraphql< - Record - >( - query, - { owner: args.owner, num: args.projectNumber, q: args.query }, - projectGhExecOptions(args.host) - ) - if (!res.ok) { - return null - } - const count = res.data[root]?.projectV2?.items?.totalCount - return typeof count === 'number' ? count : null -} - -// ─── Public: getProjectViewTable ────────────────────────────────────── +import { assertPositiveInt, assertSlug } from './project-view/internals' +import { fetchAllItems, fetchItemsCountOnly } from './project-view/item-pagination' +import { + fetchProjectViewsPage, + fetchViewFieldsContinuation, + finalizeView, + matchesSelector, + type RawProjectView +} from './project-view/view-configuration-query' +import type { RawProjectV2Field } from './project-view/response-normalization' export async function getProjectViewTable( args: GetProjectViewTableArgs @@ -1203,23 +30,18 @@ export async function getProjectViewTable( if (!ownerCheck.ok) { return { ok: false, error: ownerCheck.error } } - const numCheck = assertPositiveInt(args.projectNumber, 'projectNumber') - if (!numCheck.ok) { - return { ok: false, error: numCheck.error } + const numberCheck = assertPositiveInt(args.projectNumber, 'projectNumber') + if (!numberCheck.ok) { + return { ok: false, error: numberCheck.error } } if (args.ownerType !== 'organization' && args.ownerType !== 'user') { - return { - ok: false, - error: { type: 'validation_error', message: 'Invalid ownerType.' } - } + return { ok: false, error: { type: 'validation_error', message: 'Invalid ownerType.' } } } - // Paginate views until a match is found. let cursor: string | null = null let project: { id: string; title: string; url: string } | null = null let selectedRaw: RawProjectView | null = null let matchStrength: 'id' | 'number' | 'name' | 'default' | null = null - const viewsSeen: RawProjectView[] = [] while (true) { const page = await fetchProjectViewsPage({ owner: args.owner, @@ -1232,29 +54,24 @@ export async function getProjectViewTable( return { ok: false, error: page.error } } project = page.project - for (const v of page.views) { - viewsSeen.push(v) - const m = matchesSelector(v, { + for (const view of page.views) { + const match = matchesSelector(view, { viewId: args.viewId, viewNumber: args.viewNumber, viewName: args.viewName }) - if (m === 'none') { + if (match === 'none') { continue } - // Precedence: id > number > name > default. - const rank: Record = { id: 4, number: 3, name: 2, default: 1 } + const rank: Record = { id: 4, number: 3, name: 2, default: 1 } const currentRank = matchStrength ? rank[matchStrength] : 0 - if (!selectedRaw || rank[m] > currentRank) { - selectedRaw = v - matchStrength = m + if (!selectedRaw || rank[match] > currentRank) { + selectedRaw = view + matchStrength = match } } - // Why: stop on ANY match (incl. 'default' = first table view); walking further pages costs a GraphQL call per page with no re-ranking upside. - if (selectedRaw) { - break - } - if (!page.hasNextPage) { + // Any match is final because later pages cannot improve the requested selector. + if (selectedRaw || !page.hasNextPage) { break } cursor = page.endCursor @@ -1269,28 +86,32 @@ export async function getProjectViewTable( return { ok: false, error: { type: 'not_found', message: 'Could not find the selected view.' } } } - // Paginate view fields if necessary. let extraFields: RawProjectV2Field[] = [] - const fieldsPi = selectedRaw.fields?.pageInfo - if (fieldsPi?.hasNextPage === true && typeof fieldsPi.endCursor === 'string' && selectedRaw.id) { - const cont = await fetchViewFieldsContinuation(selectedRaw.id, fieldsPi.endCursor, args.host) - if (!cont.ok) { - return { ok: false, error: cont.error } + const fieldsPageInfo = selectedRaw.fields?.pageInfo + if ( + fieldsPageInfo?.hasNextPage === true && + typeof fieldsPageInfo.endCursor === 'string' && + selectedRaw.id + ) { + const continuation = await fetchViewFieldsContinuation( + selectedRaw.id, + fieldsPageInfo.endCursor, + args.host + ) + if (!continuation.ok) { + return { ok: false, error: continuation.error } } - extraFields = cont.fields + extraFields = continuation.fields } - const finalized = finalizeView(selectedRaw, extraFields) if (!finalized.ok) { return { ok: false, error: finalized.drift } } const selectedView = finalized.view - - // Why: empty-string override means "no filter"; undefined means "use the view's stored filter". The override is ephemeral, never persisted. + // Empty string intentionally overrides the stored filter; undefined uses it. const effectiveQuery = typeof args.queryOverride === 'string' ? args.queryOverride : selectedView.filter - // Unsupported layout: skip item pagination; best-effort count-only query. if (selectedView.layout !== 'TABLE_LAYOUT') { const count = await fetchItemsCountOnly({ owner: args.owner, @@ -1309,7 +130,6 @@ export async function getProjectViewTable( } } - // Fetch items. const items = await fetchAllItems({ owner: args.owner, ownerType: args.ownerType, @@ -1324,7 +144,6 @@ export async function getProjectViewTable( ...(typeof items.totalCount === 'number' ? { totalCount: items.totalCount } : {}) } } - const table: GitHubProjectTable = { project: { id: project.id, @@ -1343,410 +162,6 @@ export async function getProjectViewTable( return { ok: true, data: table } } -// ─── listAccessibleProjects ──────────────────────────────────────────── - -type RawViewerDiscovery = { - viewer?: { - login?: string - projectsV2?: { - pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } - nodes?: ({ - id?: string - number?: number - title?: string - url?: string - owner?: { __typename?: string; login?: string } - } | null)[] - } - organizations?: { - pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } - nodes?: ({ - login?: string - projectsV2?: { - pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } - nodes?: ({ id?: string; number?: number; title?: string; url?: string } | null)[] - } - } | null)[] - } - } -} - -export async function listAccessibleProjects( - args?: ListAccessibleProjectsArgs -): Promise { - const host = githubProjectHost(args?.host) - const viewerProjects: GitHubProjectSummary[] = [] - const orgProjects: GitHubProjectSummary[] = [] - // Why: collect per-org failures so the picker shows a "some orgs didn't load" banner instead of aborting discovery on the first 504. - const partialFailures: { owner: string; message: string }[] = [] - let viewerLogin: string | null = null - - // 1) Viewer projects (paginated, single owner so cap at DISCOVERY_PROJECTS_PER_OWNER total). - let viewerCursor: string | null = null - let viewerMore = true - let viewerFetched = 0 - while (viewerMore && viewerFetched < DISCOVERY_PROJECTS_PER_OWNER) { - const afterArg = viewerCursor ? ', after: $after' : '' - const afterVar = viewerCursor ? '$after:String!' : '' - const query = ` - query${afterVar ? `(${afterVar})` : ''} { - viewer { - login - projectsV2(first:${DISCOVERY_PROJECTS_PER_ORG}${afterArg}) { - pageInfo { hasNextPage endCursor } - nodes { - id number title url - owner { __typename ... on Organization { login } ... on User { login } } - } - } - } - } - ` - const vars: GraphqlVars = {} - if (viewerCursor) { - vars.after = viewerCursor - } - const res = await runGraphql(query, vars, projectGhExecOptions(host)) - if (!res.ok) { - // Why: viewer-level failure is structural (no projects to build on), so propagate hard; org-level errors below are non-fatal. - return { ok: false, error: res.error } - } - if (!res.data.viewer) { - return { ok: false, error: driftError('viewer missing') } - } - if (viewerLogin === null) { - viewerLogin = res.data.viewer.login ?? null - } - const nodes = res.data.viewer.projectsV2?.nodes ?? [] - for (const n of nodes) { - if (!n || typeof n.id !== 'string' || typeof n.number !== 'number') { - continue - } - const ownerLogin = n.owner?.login ?? viewerLogin ?? '' - const ownerType: GitHubProjectOwnerType = - n.owner?.__typename === 'Organization' ? 'organization' : 'user' - viewerProjects.push({ - id: n.id, - host, - owner: ownerLogin, - ownerType, - number: n.number, - title: n.title ?? '', - url: n.url ?? '', - source: 'viewer' - }) - viewerFetched++ - if (viewerFetched >= DISCOVERY_PROJECTS_PER_OWNER) { - break - } - } - const pi = res.data.viewer.projectsV2?.pageInfo - viewerMore = pi?.hasNextPage === true && typeof pi.endCursor === 'string' - viewerCursor = viewerMore ? (pi?.endCursor ?? null) : null - } - - // 2) Organizations the viewer belongs to, each with its projectsV2. - // Why: no per-org projectsV2 continuation loop — it was the dominant 504 cost; users past the cap can paste a URL instead. - let orgCursor: string | null = null - let orgMore = true - let orgsSeen = 0 - while (orgMore && orgsSeen < DISCOVERY_MAX_ORGS) { - const afterArg = orgCursor ? ', after: $orgAfter' : '' - const afterVar = orgCursor ? '$orgAfter:String!' : '' - const query = ` - query${afterVar ? `(${afterVar})` : ''} { - viewer { - organizations(first:${DISCOVERY_ORG_PAGE_SIZE}${afterArg}) { - pageInfo { hasNextPage endCursor } - nodes { - login - projectsV2(first:${DISCOVERY_PROJECTS_PER_ORG}) { - pageInfo { hasNextPage endCursor } - nodes { id number title url } - } - } - } - } - } - ` - const vars: GraphqlVars = {} - if (orgCursor) { - vars.orgAfter = orgCursor - } - const res = await runGraphql(query, vars, projectGhExecOptions(host)) - if (!res.ok) { - // Why: org-listing failed; record a synthetic '*' partial failure so the banner explains it, but keep collected viewer projects (the reported 504 path). - partialFailures.push({ owner: '*', message: res.error.message }) - break - } - const orgs = res.data.viewer?.organizations?.nodes ?? [] - for (const org of orgs) { - if (!org || typeof org.login !== 'string') { - continue - } - if (orgsSeen >= DISCOVERY_MAX_ORGS) { - break - } - orgsSeen++ - const login = org.login - // Cache for paste/resolve even when the nested projects query was empty or partially failed. - rememberOwnerType(login, 'organization', host) - const nodes = org.projectsV2?.nodes ?? [] - let ownerCount = 0 - for (const n of nodes) { - if (!n || typeof n.id !== 'string' || typeof n.number !== 'number') { - continue - } - if (ownerCount >= DISCOVERY_PROJECTS_PER_OWNER) { - break - } - orgProjects.push({ - id: n.id, - host, - owner: login, - ownerType: 'organization', - number: n.number, - title: n.title ?? '', - url: n.url ?? '', - source: `org:${login}` - }) - ownerCount++ - } - } - const pi = res.data.viewer?.organizations?.pageInfo - orgMore = pi?.hasNextPage === true && typeof pi.endCursor === 'string' - orgCursor = orgMore ? (pi?.endCursor ?? null) : null - } - - if (viewerLogin) { - rememberOwnerType(viewerLogin, 'user', host) - } - - return { - ok: true, - projects: [...viewerProjects, ...orgProjects], - ...(partialFailures.length > 0 ? { partialFailures } : {}) - } -} - -// ─── resolveProjectRef ───────────────────────────────────────────────── - -type ParsedPaste = - | { kind: 'org'; owner: string; number: number; host: string; viewNumber?: number } - | { kind: 'user'; owner: string; number: number; host: string; viewNumber?: number } - | { kind: 'bare'; owner: string; number: number } - -export function parseProjectPaste(input: string, host?: string): ParsedPaste | null { - const trimmed = input.trim() - if (!trimmed) { - return null - } - if (isGitHubProjectRefInputTooLarge(trimmed)) { - return null - } - // Why: URL parsing enforces an exact Project path and rejects credentials; - // a prefix regex could silently turn `/projects/1evil` into Project 1. - try { - const url = new URL(trimmed) - const allowedHosts = new Set(['github.com', ...(host ? [host.trim().toLowerCase()] : [])]) - const parts = url.pathname.split('/').filter(Boolean) - const hasView = parts.length === 6 && parts[4] === 'views' - if ( - (url.protocol !== 'https:' && url.protocol !== 'http:') || - url.username || - url.password || - !allowedHosts.has(url.host.toLowerCase()) || - (parts[0] !== 'orgs' && parts[0] !== 'users') || - !isValidOwnerSlug(parts[1]) || - parts[2] !== 'projects' || - (parts.length !== 4 && !hasView) - ) { - return null - } - const number = Number(parts[3]) - const viewNumber = hasView ? Number(parts[5]) : undefined - if ( - !Number.isSafeInteger(number) || - number < 1 || - (hasView && (!Number.isSafeInteger(viewNumber) || (viewNumber ?? 0) < 1)) - ) { - return null - } - return { - kind: parts[0] === 'orgs' ? 'org' : 'user', - owner: parts[1], - number, - host: url.host.toLowerCase(), - ...(viewNumber !== undefined ? { viewNumber } : {}) - } - } catch { - // Shorthand parsing below remains available for non-URL input. - } - // owner/number shorthand — owner alphabet matches OWNER_SLUG_RE. - const shortRe = /^([A-Za-z0-9][A-Za-z0-9-]*)\/(\d+)$/ - const sm = trimmed.match(shortRe) - if (sm) { - const number = Number.parseInt(sm[2], 10) - if (!Number.isInteger(number) || number < 1) { - return null - } - return { kind: 'bare', owner: sm[1], number } - } - return null -} - -async function resolveOwnerType( - owner: string, - preferred: GitHubProjectOwnerType | null, - host?: string -): Promise< - | { ok: true; ownerType: GitHubProjectOwnerType; title: string } - | { ok: false; error: GitHubProjectViewError } -> { - const tryOne = async ( - ot: GitHubProjectOwnerType, - num: number | null - ): Promise<{ ok: true; title: string } | { ok: false; error: GitHubProjectViewError }> => { - const root = ownerQueryRoot(ot) - // If number is provided, fetch the project title; else just confirm owner exists. - const query = num - ? ` - query($owner:String!, $num:Int!) { - ${root}(login:$owner) { projectV2(number:$num) { id title } } - } - ` - : ` - query($owner:String!) { - ${root}(login:$owner) { login } - } - ` - const vars: GraphqlVars = { owner } - if (num) { - vars.num = num - } - const res = await runGraphql< - Record - >(query, vars, projectGhExecOptions(host)) - if (!res.ok) { - return { ok: false, error: res.error } - } - const top = res.data[root] - if (!top) { - return { ok: false, error: { type: 'not_found', message: 'Owner not found.' } } - } - if (num) { - const p = top.projectV2 - if (!p || typeof p.id !== 'string') { - return { ok: false, error: { type: 'not_found', message: 'Project not found.' } } - } - return { ok: true, title: p.title ?? '' } - } - return { ok: true, title: '' } - } - - const cached = getCachedOwnerType(owner, host) - const candidates: GitHubProjectOwnerType[] = preferred - ? [preferred] - : cached - ? [cached] - : ['organization', 'user'] - const fallback: GitHubProjectOwnerType[] = preferred - ? [] - : cached - ? cached === 'organization' - ? ['user'] - : ['organization'] - : [] - const ordered = [...candidates, ...fallback] - let lastError: GitHubProjectViewError | null = null - for (const ot of ordered) { - const r = await tryOne(ot, null) - if (r.ok) { - rememberOwnerType(owner, ot, host) - return { ok: true, ownerType: ot, title: r.title } - } - lastError = r.error - if (r.error.type !== 'not_found') { - // Non-NOT_FOUND errors (auth, network, rate) should not trigger fallback. - return { ok: false, error: r.error } - } - } - rememberOwnerType(owner, null, host) - return { - ok: false, - error: lastError ?? { type: 'not_found', message: 'Owner not found.' } - } -} - -export async function resolveProjectRef( - args: ResolveProjectRefArgs -): Promise { - const input = typeof args.input === 'string' ? args.input.trim() : '' - if (!input) { - return { - ok: false, - error: { type: 'validation_error', message: 'Input required.' } - } - } - if (isGitHubProjectRefInputTooLarge(input)) { - return { - ok: false, - error: { type: 'validation_error', message: GITHUB_PROJECT_REF_INPUT_TOO_LARGE_ERROR } - } - } - const parsed = parseProjectPaste(input, args.host) - if (!parsed) { - return { - ok: false, - error: { - type: 'validation_error', - message: 'Could not parse input. Expected a GitHub project URL or `owner/number`.' - } - } - } - const preferred: GitHubProjectOwnerType | null = - parsed.kind === 'org' ? 'organization' : parsed.kind === 'user' ? 'user' : null - // Why: a pasted URL is authoritative. The ambient host only applies to - // owner/number shorthand; otherwise same-number Projects can cross hosts. - const executionHost = parsed.kind === 'bare' ? githubProjectHost(args.host) : parsed.host - // Verify by fetching project title. - const ownerRes = await resolveOwnerType(parsed.owner, preferred, executionHost) - if (!ownerRes.ok) { - return { ok: false, error: ownerRes.error } - } - const ownerType = ownerRes.ownerType - const root = ownerQueryRoot(ownerType) - const query = ` - query($owner:String!, $num:Int!) { - ${root}(login:$owner) { projectV2(number:$num) { id title } } - } - ` - const res = await runGraphql< - Record - >(query, { owner: parsed.owner, num: parsed.number }, projectGhExecOptions(executionHost)) - if (!res.ok) { - return { ok: false, error: res.error } - } - const p = res.data[root]?.projectV2 - if (!p || typeof p.id !== 'string') { - return { ok: false, error: { type: 'not_found', message: 'Project not found.' } } - } - return { - ok: true, - owner: parsed.owner, - ownerType, - number: parsed.number, - title: p.title ?? '', - host: executionHost, - // Why: forward URL view numbers so the renderer can skip view selection; bare shorthand has none. - ...(parsed.kind !== 'bare' && parsed.viewNumber !== undefined - ? { viewNumber: parsed.viewNumber } - : {}) - } -} - -// ─── listProjectViews ────────────────────────────────────────────────── - export async function listProjectViews( args: ListProjectViewsArgs ): Promise { @@ -1754,9 +169,9 @@ export async function listProjectViews( if (!ownerCheck.ok) { return { ok: false, error: ownerCheck.error } } - const numCheck = assertPositiveInt(args.projectNumber, 'projectNumber') - if (!numCheck.ok) { - return { ok: false, error: numCheck.error } + const numberCheck = assertPositiveInt(args.projectNumber, 'projectNumber') + if (!numberCheck.ok) { + return { ok: false, error: numberCheck.error } } if (args.ownerType !== 'organization' && args.ownerType !== 'user') { return { ok: false, error: { type: 'validation_error', message: 'Invalid ownerType.' } } @@ -1774,15 +189,15 @@ export async function listProjectViews( if (!page.ok) { return { ok: false, error: page.error } } - for (const v of page.views) { - if (typeof v.id !== 'string' || typeof v.layout !== 'string') { + for (const view of page.views) { + if (typeof view.id !== 'string' || typeof view.layout !== 'string') { continue } summaries.push({ - id: v.id, - number: typeof v.number === 'number' ? v.number : 0, - name: typeof v.name === 'string' ? v.name : '', - layout: v.layout as GitHubProjectViewLayout + id: view.id, + number: typeof view.number === 'number' ? view.number : 0, + name: typeof view.name === 'string' ? view.name : '', + layout: view.layout as GitHubProjectViewLayout }) } if (!page.hasNextPage) { diff --git a/src/main/github/project-view/accessible-project-discovery.ts b/src/main/github/project-view/accessible-project-discovery.ts new file mode 100644 index 00000000000..866542d34de --- /dev/null +++ b/src/main/github/project-view/accessible-project-discovery.ts @@ -0,0 +1,188 @@ +import type { + GitHubProjectOwnerType, + GitHubProjectSummary +} from '../../../shared/github/project-types' +import type { ListAccessibleProjectsResult } from '../../../shared/github/project-result-types' +import type { ListAccessibleProjectsArgs } from '../../../shared/github/project-request-types' +import { githubProjectHost } from '../../../shared/github/project-identity' +import { rememberOwnerType } from './cache-state' +import { driftError } from './project-error-classification' +import { projectGhExecOptions, runGraphql, type GraphqlVars } from './internals' + +const DISCOVERY_PROJECTS_PER_OWNER = 40 +const DISCOVERY_MAX_ORGS = 20 +const DISCOVERY_ORG_PAGE_SIZE = 20 +const DISCOVERY_PROJECTS_PER_ORG = 20 + +type RawViewerDiscovery = { + viewer?: { + login?: string + projectsV2?: { + pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } + nodes?: ({ + id?: string + number?: number + title?: string + url?: string + owner?: { __typename?: string; login?: string } + } | null)[] + } + organizations?: { + pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } + nodes?: ({ + login?: string + projectsV2?: { + pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } + nodes?: ({ id?: string; number?: number; title?: string; url?: string } | null)[] + } + } | null)[] + } + } +} + +export async function listAccessibleProjects( + args?: ListAccessibleProjectsArgs +): Promise { + const host = githubProjectHost(args?.host) + const viewerProjects: GitHubProjectSummary[] = [] + const orgProjects: GitHubProjectSummary[] = [] + // Preserve per-org failures so discovery can return the usable partial result. + const partialFailures: { owner: string; message: string }[] = [] + let viewerLogin: string | null = null + let viewerCursor: string | null = null + let viewerMore = true + let viewerFetched = 0 + while (viewerMore && viewerFetched < DISCOVERY_PROJECTS_PER_OWNER) { + const afterArg = viewerCursor ? ', after: $after' : '' + const afterVar = viewerCursor ? '$after:String!' : '' + const query = ` + query${afterVar ? `(${afterVar})` : ''} { + viewer { + login + projectsV2(first:${DISCOVERY_PROJECTS_PER_ORG}${afterArg}) { + pageInfo { hasNextPage endCursor } + nodes { + id number title url + owner { __typename ... on Organization { login } ... on User { login } } + } + } + } + } + ` + const vars: GraphqlVars = {} + if (viewerCursor) { + vars.after = viewerCursor + } + const result = await runGraphql(query, vars, projectGhExecOptions(host)) + if (!result.ok) { + return { ok: false, error: result.error } + } + if (!result.data.viewer) { + return { ok: false, error: driftError('viewer missing') } + } + if (viewerLogin === null) { + viewerLogin = result.data.viewer.login ?? null + } + for (const project of result.data.viewer.projectsV2?.nodes ?? []) { + if (!project || typeof project.id !== 'string' || typeof project.number !== 'number') { + continue + } + const ownerType: GitHubProjectOwnerType = + project.owner?.__typename === 'Organization' ? 'organization' : 'user' + viewerProjects.push({ + id: project.id, + host, + owner: project.owner?.login ?? viewerLogin ?? '', + ownerType, + number: project.number, + title: project.title ?? '', + url: project.url ?? '', + source: 'viewer' + }) + viewerFetched++ + if (viewerFetched >= DISCOVERY_PROJECTS_PER_OWNER) { + break + } + } + const pageInfo = result.data.viewer.projectsV2?.pageInfo + viewerMore = pageInfo?.hasNextPage === true && typeof pageInfo.endCursor === 'string' + viewerCursor = viewerMore ? (pageInfo?.endCursor ?? null) : null + } + + // Fetch one bounded projects page per organization; overflow projects remain paste-accessible. + let orgCursor: string | null = null + let orgMore = true + let orgsSeen = 0 + while (orgMore && orgsSeen < DISCOVERY_MAX_ORGS) { + const afterArg = orgCursor ? ', after: $orgAfter' : '' + const afterVar = orgCursor ? '$orgAfter:String!' : '' + const query = ` + query${afterVar ? `(${afterVar})` : ''} { + viewer { + organizations(first:${DISCOVERY_ORG_PAGE_SIZE}${afterArg}) { + pageInfo { hasNextPage endCursor } + nodes { + login + projectsV2(first:${DISCOVERY_PROJECTS_PER_ORG}) { + pageInfo { hasNextPage endCursor } + nodes { id number title url } + } + } + } + } + } + ` + const vars: GraphqlVars = {} + if (orgCursor) { + vars.orgAfter = orgCursor + } + const result = await runGraphql(query, vars, projectGhExecOptions(host)) + if (!result.ok) { + partialFailures.push({ owner: '*', message: result.error.message }) + break + } + for (const organization of result.data.viewer?.organizations?.nodes ?? []) { + if (!organization || typeof organization.login !== 'string') { + continue + } + if (orgsSeen >= DISCOVERY_MAX_ORGS) { + break + } + orgsSeen++ + const login = organization.login + rememberOwnerType(login, 'organization', host) + let ownerCount = 0 + for (const project of organization.projectsV2?.nodes ?? []) { + if (!project || typeof project.id !== 'string' || typeof project.number !== 'number') { + continue + } + if (ownerCount >= DISCOVERY_PROJECTS_PER_OWNER) { + break + } + orgProjects.push({ + id: project.id, + host, + owner: login, + ownerType: 'organization', + number: project.number, + title: project.title ?? '', + url: project.url ?? '', + source: `org:${login}` + }) + ownerCount++ + } + } + const pageInfo = result.data.viewer?.organizations?.pageInfo + orgMore = pageInfo?.hasNextPage === true && typeof pageInfo.endCursor === 'string' + orgCursor = orgMore ? (pageInfo?.endCursor ?? null) : null + } + + if (viewerLogin) { + rememberOwnerType(viewerLogin, 'user', host) + } + return { + ok: true, + projects: [...viewerProjects, ...orgProjects], + ...(partialFailures.length > 0 ? { partialFailures } : {}) + } +} diff --git a/src/main/github/project-view/cache-state.ts b/src/main/github/project-view/cache-state.ts new file mode 100644 index 00000000000..c29dd3e3855 --- /dev/null +++ b/src/main/github/project-view/cache-state.ts @@ -0,0 +1,143 @@ +import { githubProjectHost } from '../../../shared/github/project-identity' +import type { GitHubProjectOwnerType } from '../../../shared/github/project-types' + +export const PROJECT_VIEW_OWNER_CACHE_MAX_ENTRIES = 512 + +// Why: owners are user-controlled over a long session; bound cache entries to avoid unbounded retention while keeping the hot-owner fast path. +function rememberProjectViewCacheEntry( + cache: Map, + key: K, + value: V, + maxEntries = PROJECT_VIEW_OWNER_CACHE_MAX_ENTRIES +): void { + if (cache.has(key)) { + cache.delete(key) + } + cache.set(key, value) + while (cache.size > maxEntries) { + const oldest = cache.keys().next() + if (oldest.done) { + break + } + cache.delete(oldest.value) + } +} + +function getProjectViewCacheEntry(cache: Map, key: K): V | undefined { + if (!cache.has(key)) { + return undefined + } + const value = cache.get(key) as V + rememberProjectViewCacheEntry(cache, key, value) + return value +} + +// Why: plain module locals so HMR code swaps re-run capability probes instead of carrying a stale "unsupported" flag. +const ownerTypeCache = new Map() +// Why: keyed per owner (not a process-global flag) so one owner's capability gap doesn't poison others that DO support Issue.parent. +const parentFieldRetriedByOwner = new Map() +const parentFieldWarningLoggedByOwner = new Map() +// Why: concurrent item fetches for one owner share the capability probe. +export const parentFieldProbeInFlight = new Map>() + +// GHES owners are a separate namespace and capability surface from github.com owners. +export function ownerScopeKey( + owner: string, + ownerType: GitHubProjectOwnerType, + host?: string +): string { + return `${owner}\u0000${ownerType}\u0000${githubProjectHost(host)}` +} + +function ownerTypeCacheKey(owner: string, host?: string): string { + return `${owner}\u0000${githubProjectHost(host)}` +} + +export function rememberOwnerType( + owner: string, + ownerType: GitHubProjectOwnerType | null, + host?: string +): void { + rememberProjectViewCacheEntry(ownerTypeCache, ownerTypeCacheKey(owner, host), ownerType) +} + +export function getCachedOwnerType( + owner: string, + host?: string +): GitHubProjectOwnerType | null | undefined { + return getProjectViewCacheEntry(ownerTypeCache, ownerTypeCacheKey(owner, host)) +} + +export function markParentFieldRetried(scopeKey: string): void { + rememberProjectViewCacheEntry(parentFieldRetriedByOwner, scopeKey, true) +} + +export function hasParentFieldRetried(scopeKey: string): boolean { + return getProjectViewCacheEntry(parentFieldRetriedByOwner, scopeKey) === true +} + +export function markParentFieldWarningLogged(scopeKey: string): void { + rememberProjectViewCacheEntry(parentFieldWarningLoggedByOwner, scopeKey, true) +} + +export function hasParentFieldWarningLogged(scopeKey: string): boolean { + return getProjectViewCacheEntry(parentFieldWarningLoggedByOwner, scopeKey) === true +} + +export function _resetProjectViewCachesForTests(): void { + ownerTypeCache.clear() + parentFieldRetriedByOwner.clear() + parentFieldWarningLoggedByOwner.clear() + parentFieldProbeInFlight.clear() +} + +export function _getProjectViewCacheSizesForTests(): { + ownerTypes: number + parentFieldRetries: number + parentFieldWarnings: number + parentFieldProbes: number +} { + return { + ownerTypes: ownerTypeCache.size, + parentFieldRetries: parentFieldRetriedByOwner.size, + parentFieldWarnings: parentFieldWarningLoggedByOwner.size, + parentFieldProbes: parentFieldProbeInFlight.size + } +} + +/** @internal - exposed for cache-bound tests only. */ +export function _rememberProjectViewOwnerTypeForTests( + owner: string, + ownerType: GitHubProjectOwnerType | null, + host?: string +): void { + rememberOwnerType(owner, ownerType, host) +} + +/** @internal - exposed for cache-bound tests only. */ +export function _getProjectViewOwnerTypeForTests( + owner: string, + host?: string +): GitHubProjectOwnerType | null | undefined { + return getCachedOwnerType(owner, host) +} + +/** @internal - exposed for cache-bound tests only. */ +export function _markProjectViewParentFieldRetriedForTests(scopeKey: string): void { + markParentFieldRetried(scopeKey) +} + +/** @internal - exposed for cache-bound tests only. */ +export function _hasProjectViewParentFieldRetriedForTests(scopeKey: string): boolean { + return hasParentFieldRetried(scopeKey) +} + +/** @internal - exposed for cache-bound tests only. */ +export function _markProjectViewParentFieldWarningLoggedForTests(scopeKey: string): void { + markParentFieldWarningLogged(scopeKey) +} + +/** @internal - exposed for cache-bound tests only. */ +export function _hasProjectViewParentFieldWarningLoggedForTests(scopeKey: string): boolean { + return hasParentFieldWarningLogged(scopeKey) +} diff --git a/src/main/github/project-view/internals.ts b/src/main/github/project-view/internals.ts index 1c828909669..b5f3bde5f98 100644 --- a/src/main/github/project-view/internals.ts +++ b/src/main/github/project-view/internals.ts @@ -3,7 +3,8 @@ // every gh call through the runner gives us transient-5xx retry, WSL path // translation, and a single hook point for future quota tracking. import { acquire, release } from '../gh-utils' -import { extractExecError, ghExecFileAsync } from '../../git/runner' +import { ghExecFileAsync } from '../../git/github-cli-runner' +import { extractExecError } from '../../git/exec-error' import { repositoryRateLimitGuard, noteRepositoryRateLimitSpend, @@ -24,7 +25,6 @@ export { acquire, release, extractExecError, - ghExecFileAsync, repositoryRateLimitGuard, noteRepositoryRateLimitSpend } diff --git a/src/main/github/project-view/issue-comment-mutations.ts b/src/main/github/project-view/issue-comment-mutations.ts new file mode 100644 index 00000000000..d152d2333cb --- /dev/null +++ b/src/main/github/project-view/issue-comment-mutations.ts @@ -0,0 +1,118 @@ +import { assertPositiveInt, projectGhExecOptions, runRest, validateSlugArgs } from './internals' +import type { PRComment } from '../../../shared/github/comment-types' +import type { + GitHubProjectCommentMutationResult, + GitHubProjectMutationResult +} from '../../../shared/github/project-result-types' +import type { + AddIssueCommentBySlugArgs, + DeleteIssueCommentBySlugArgs, + UpdateIssueCommentBySlugArgs +} from '../../../shared/github/project-request-types' + +type RawIssueCommentResponse = { + id?: number + user?: { login?: string; avatar_url?: string; type?: string } | null + body?: string + created_at?: string + html_url?: string +} + +function mapIssueComment(data: RawIssueCommentResponse, fallbackBody: string): PRComment { + return { + id: data.id ?? Date.now(), + author: data.user?.login ?? 'You', + authorAvatarUrl: data.user?.avatar_url ?? '', + body: data.body ?? fallbackBody, + createdAt: data.created_at ?? new Date().toISOString(), + url: data.html_url ?? '', + isBot: data.user?.type === 'Bot' + } +} + +export async function addIssueCommentBySlug( + args: AddIssueCommentBySlugArgs +): Promise { + const v = validateSlugArgs(args.owner, args.repo) + if (!v.ok) { + return v + } + const n = assertPositiveInt(args.number, 'number') + if (!n.ok) { + return { ok: false, error: n.error } + } + if (typeof args.body !== 'string' || !args.body.trim()) { + return { ok: false, error: { type: 'validation_error', message: 'Comment body required.' } } + } + const r = await runRest( + [ + '-X', + 'POST', + `repos/${args.owner}/${args.repo}/issues/${args.number}/comments`, + '--raw-field', + `body=${args.body}` + ], + undefined, + 'core', + projectGhExecOptions(args.host) + ) + if (!r.ok) { + return { ok: false, error: r.error } + } + return { ok: true, comment: mapIssueComment(r.data, args.body) } +} + +export async function updateIssueCommentBySlug( + args: UpdateIssueCommentBySlugArgs +): Promise { + const v = validateSlugArgs(args.owner, args.repo) + if (!v.ok) { + return v + } + const n = assertPositiveInt(args.commentId, 'commentId') + if (!n.ok) { + return { ok: false, error: n.error } + } + if (typeof args.body !== 'string' || !args.body.trim()) { + return { ok: false, error: { type: 'validation_error', message: 'Comment body required.' } } + } + const r = await runRest( + [ + '-X', + 'PATCH', + `repos/${args.owner}/${args.repo}/issues/comments/${args.commentId}`, + '--raw-field', + `body=${args.body}` + ], + undefined, + 'core', + projectGhExecOptions(args.host) + ) + if (!r.ok) { + return { ok: false, error: r.error } + } + return { ok: true } +} + +export async function deleteIssueCommentBySlug( + args: DeleteIssueCommentBySlugArgs +): Promise { + const v = validateSlugArgs(args.owner, args.repo) + if (!v.ok) { + return v + } + const n = assertPositiveInt(args.commentId, 'commentId') + if (!n.ok) { + return { ok: false, error: n.error } + } + const r = await runRest( + ['-X', 'DELETE', `repos/${args.owner}/${args.repo}/issues/comments/${args.commentId}`], + undefined, + 'core', + { expectEmpty: true, ...projectGhExecOptions(args.host) } + ) + if (!r.ok) { + return { ok: false, error: r.error } + } + return { ok: true } +} diff --git a/src/main/github/project-view/item-page-query.ts b/src/main/github/project-view/item-page-query.ts new file mode 100644 index 00000000000..7fcb0e18f3f --- /dev/null +++ b/src/main/github/project-view/item-page-query.ts @@ -0,0 +1,206 @@ +import { ghExecFileAsync } from '../../git/github-cli-runner' +import type { GitHubProjectOwnerType } from '../../../shared/github/project-types' +import type { GitHubProjectViewError } from '../../../shared/github/project-result-types' +import { + acquire, + extractExecError, + noteRepositoryRateLimitSpend, + projectGhExecOptions, + projectHostAuthenticationError, + release, + repositoryRateLimitGuard +} from './internals' +import { + classifyProjectError, + driftError, + rateLimitedError, + type GhGraphqlErrorShape +} from './project-error-classification' +import type { RawProjectItem } from './response-normalization' +import { FIELD_CONFIG_FRAGMENT, ownerQueryRoot } from './view-configuration-query' + +const FIELD_VALUES_PAGE_SIZE = 100 + +function itemContentSelection(includeParent: boolean): string { + const parentFragment = includeParent ? 'parent { number title url }' : '' + return ` + __typename + ... on Issue { + id + number + title + url + state + stateReason + repository { nameWithOwner } + assignees(first:5) { nodes { login name avatarUrl } } + labels(first:10) { nodes { name color } } + issueType { id name color description } + ${parentFragment} + } + ... on PullRequest { + id + number + title + url + state + isDraft + repository { nameWithOwner } + assignees(first:5) { nodes { login name avatarUrl } } + labels(first:10) { nodes { name color } } + } + ... on DraftIssue { id title body } + ` +} + +const FIELD_VALUES_SELECTION = ` + fieldValues(first:${FIELD_VALUES_PAGE_SIZE}) { + pageInfo { hasNextPage } + nodes { + __typename + ... on ProjectV2ItemFieldSingleSelectValue { field { ...FieldConfig } name color optionId } + ... on ProjectV2ItemFieldIterationValue { field { ...FieldConfig } title startDate duration iterationId } + ... on ProjectV2ItemFieldTextValue { field { ...FieldConfig } text } + ... on ProjectV2ItemFieldNumberValue { field { ...FieldConfig } number } + ... on ProjectV2ItemFieldDateValue { field { ...FieldConfig } date } + ... on ProjectV2ItemFieldLabelValue { field { ...FieldConfig } labels(first:10) { nodes { name color } } } + ... on ProjectV2ItemFieldUserValue { field { ...FieldConfig } users(first:5) { nodes { login name avatarUrl } } } + } + } +` + +export type RawItemsPage = { + totalCount?: number + pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } + nodes?: (RawProjectItem | null)[] +} +export type ItemsPageResult = + | { ok: true; page: RawItemsPage } + | { + ok: false + error: GitHubProjectViewError + rawErrors: GhGraphqlErrorShape[] + stderr: string + } + +// Unlike runGraphql, this preserves the raw error envelope for the Issue.parent retry. +export async function fetchItemsPageWithRaw(args: { + owner: string + ownerType: GitHubProjectOwnerType + projectNumber: number + query: string + first: number + after: string | null + includeParent: boolean + host?: string +}): Promise { + const authError = await projectHostAuthenticationError(args.host) + if (authError) { + return { ok: false, error: authError, rawErrors: [], stderr: '' } + } + const root = ownerQueryRoot(args.ownerType) + const afterArg = args.after ? `, after: $after` : '' + const afterVar = args.after ? `$after:String!, ` : '' + const query = ` + query(${afterVar}$owner:String!, $num:Int!, $q:String!, $first:Int!) { + ${root}(login:$owner) { + projectV2(number:$num) { + items(first:$first${afterArg}, query:$q, orderBy:{ field: POSITION, direction: ASC }) { + totalCount + pageInfo { hasNextPage endCursor } + nodes { + id + type + updatedAt + content { ${itemContentSelection(args.includeParent)} } + ${FIELD_VALUES_SELECTION} + } + } + } + } + } + ${FIELD_CONFIG_FRAGMENT} + ` + const commandArgs = ['api', 'graphql', '-f', `query=${query}`] + commandArgs.push('-f', `owner=${args.owner}`) + commandArgs.push('-F', `num=${args.projectNumber}`) + commandArgs.push('-f', `q=${args.query}`) + commandArgs.push('-F', `first=${args.first}`) + if (args.after) { + commandArgs.push('-f', `after=${args.after}`) + } + + // GHES traffic has its own quota; only github.com consults the shared snapshot. + const guard = repositoryRateLimitGuard(args, 'graphql') + if (guard.blocked) { + return { ok: false, error: rateLimitedError(guard), rawErrors: [], stderr: '' } + } + await acquire() + noteRepositoryRateLimitSpend(args, 'graphql') + try { + let stdout = '' + let stderr = '' + let execFailed = false + try { + const result = await ghExecFileAsync(commandArgs, { + encoding: 'utf-8', + ...projectGhExecOptions(args.host) + }) + stdout = result.stdout + stderr = result.stderr + } catch (error) { + const extracted = extractExecError(error) + stderr = extracted.stderr + stdout = extracted.stdout + execFailed = true + } + let parsed: { data?: Record; errors?: GhGraphqlErrorShape[] } = {} + try { + parsed = JSON.parse(stdout) + } catch { + if (execFailed) { + return { + ok: false, + error: classifyProjectError(stderr, stdout, args.host), + rawErrors: [], + stderr + } + } + return { + ok: false, + error: driftError('failed to parse items response'), + rawErrors: [], + stderr + } + } + if (execFailed && (!parsed.errors || parsed.errors.length === 0) && !parsed.data) { + return { + ok: false, + error: classifyProjectError(stderr, stdout, args.host), + rawErrors: [], + stderr + } + } + if (parsed.errors && parsed.errors.length > 0) { + return { + ok: false, + error: classifyProjectError(stderr, stdout, args.host), + rawErrors: parsed.errors, + stderr + } + } + const top = parsed.data?.[root] as { projectV2?: { items?: RawItemsPage } | null } | undefined + const page = top?.projectV2?.items + if (!page) { + return { + ok: false, + error: { type: 'not_found', message: 'Project or view not found.' }, + rawErrors: [], + stderr + } + } + return { ok: true, page } + } finally { + release() + } +} diff --git a/src/main/github/project-view/item-pagination.ts b/src/main/github/project-view/item-pagination.ts new file mode 100644 index 00000000000..ab589d7e4b6 --- /dev/null +++ b/src/main/github/project-view/item-pagination.ts @@ -0,0 +1,206 @@ +import type { GitHubProjectOwnerType, GitHubProjectRow } from '../../../shared/github/project-types' +import type { GitHubProjectViewError } from '../../../shared/github/project-result-types' +import { errorsIndicateParentField, driftError } from './project-error-classification' +import { projectGhExecOptions, runGraphql } from './internals' +import { + hasParentFieldRetried, + hasParentFieldWarningLogged, + markParentFieldRetried, + markParentFieldWarningLogged, + ownerScopeKey, + parentFieldProbeInFlight +} from './cache-state' +import { fetchItemsPageWithRaw, type ItemsPageResult } from './item-page-query' +import { normalizeItem, type RawProjectItem } from './response-normalization' +import { ownerQueryRoot } from './view-configuration-query' + +const ITEM_PAGE_SIZE = 100 +const MAX_ITEMS = 500 + +export async function fetchAllItems(args: { + owner: string + ownerType: GitHubProjectOwnerType + projectNumber: number + query: string + host?: string +}): Promise< + | { ok: true; rows: GitHubProjectRow[]; totalCount: number; parentFieldDropped: boolean } + | { ok: false; error: GitHubProjectViewError; totalCount?: number } +> { + const scopeKey = ownerScopeKey(args.owner, args.ownerType, args.host) + const inFlight = parentFieldProbeInFlight.get(scopeKey) + if (inFlight) { + await inFlight.catch(() => {}) + } + let includeParent = !hasParentFieldRetried(scopeKey) + let parentFieldDropped = !includeParent + let first: ItemsPageResult + if (includeParent && !parentFieldProbeInFlight.has(scopeKey)) { + let resolveProbe: () => void = () => {} + const probe = new Promise((resolve) => { + resolveProbe = resolve + }) + parentFieldProbeInFlight.set(scopeKey, probe) + const probePromise = (async (): Promise => { + try { + const result = await fetchItemsPageWithRaw({ + ...args, + first: ITEM_PAGE_SIZE, + after: null, + includeParent: true + }) + // Set state before waking siblings so none duplicate the with-parent probe. + if (!result.ok && errorsIndicateParentField(result.rawErrors, result.stderr)) { + markParentFieldRetried(scopeKey) + } + return result + } finally { + resolveProbe() + parentFieldProbeInFlight.delete(scopeKey) + } + })() + first = await probePromise + } else { + first = await fetchItemsPageWithRaw({ + ...args, + first: ITEM_PAGE_SIZE, + after: null, + includeParent + }) + } + if (!first.ok && includeParent && errorsIndicateParentField(first.rawErrors, first.stderr)) { + markParentFieldRetried(scopeKey) + includeParent = false + parentFieldDropped = true + if (!hasParentFieldWarningLogged(scopeKey)) { + console.warn( + `[project-view] Issue.parent is not available for ${args.owner} on this token — retrying without the parent selection.` + ) + markParentFieldWarningLogged(scopeKey) + } + first = await fetchItemsPageWithRaw({ + ...args, + first: ITEM_PAGE_SIZE, + after: null, + includeParent: false + }) + } + if (!first.ok) { + return { ok: false, error: first.error } + } + + if (first.page.totalCount === undefined || first.page.totalCount === null) { + return { ok: false, error: driftError('items.totalCount missing') } + } + const totalCount = first.page.totalCount + if (first.page.pageInfo?.hasNextPage === undefined) { + return { ok: false, error: driftError('items.pageInfo.hasNextPage missing'), totalCount } + } + if (!Array.isArray(first.page.nodes)) { + return { ok: false, error: driftError('items.nodes missing'), totalCount } + } + if (totalCount > MAX_ITEMS) { + return { + ok: false, + error: { type: 'too_large', message: `View has ${totalCount} items.` }, + totalCount + } + } + + const rows: GitHubProjectRow[] = [] + let position = 0 + const appendNodes = (nodes: (RawProjectItem | null)[]): GitHubProjectViewError | null => { + for (const node of nodes) { + if (!node) { + continue + } + const normalized = normalizeItem(node, position) + if (!normalized.ok) { + return normalized.drift + } + rows.push(normalized.row) + position++ + } + return null + } + const firstError = appendNodes(first.page.nodes) + if (firstError) { + return { ok: false, error: firstError, totalCount } + } + + let hasNext = first.page.pageInfo.hasNextPage === true + let cursor: string | null | undefined = first.page.pageInfo.endCursor + if (hasNext && typeof cursor !== 'string') { + return { + ok: false, + error: driftError('items.pageInfo.endCursor missing with hasNextPage=true'), + totalCount + } + } + while (hasNext) { + const next = await fetchItemsPageWithRaw({ + ...args, + first: ITEM_PAGE_SIZE, + after: cursor as string, + includeParent + }) + if (!next.ok) { + return { ok: false, error: next.error, totalCount } + } + if (!Array.isArray(next.page.nodes)) { + return { ok: false, error: driftError('items.nodes missing on follow page'), totalCount } + } + if (next.page.pageInfo?.hasNextPage === undefined) { + return { + ok: false, + error: driftError('items.pageInfo.hasNextPage missing on follow page'), + totalCount + } + } + const pageError = appendNodes(next.page.nodes) + if (pageError) { + return { ok: false, error: pageError, totalCount } + } + hasNext = next.page.pageInfo.hasNextPage === true + cursor = next.page.pageInfo.endCursor + if (hasNext && typeof cursor !== 'string') { + return { + ok: false, + error: driftError('items.pageInfo.endCursor missing with hasNextPage=true'), + totalCount + } + } + } + return { ok: true, rows, totalCount, parentFieldDropped } +} + +export async function fetchItemsCountOnly(args: { + owner: string + ownerType: GitHubProjectOwnerType + projectNumber: number + query: string + host?: string +}): Promise { + const root = ownerQueryRoot(args.ownerType) + const query = ` + query($owner:String!, $num:Int!, $q:String!) { + ${root}(login:$owner) { + projectV2(number:$num) { + items(first:1, query:$q) { totalCount } + } + } + } + ` + const result = await runGraphql< + Record + >( + query, + { owner: args.owner, num: args.projectNumber, q: args.query }, + projectGhExecOptions(args.host) + ) + if (!result.ok) { + return null + } + const count = result.data[root]?.projectV2?.items?.totalCount + return typeof count === 'number' ? count : null +} diff --git a/src/main/github/project-view/mutations.test.ts b/src/main/github/project-view/mutations.test.ts index 30a00a0f110..e58da3c59c8 100644 --- a/src/main/github/project-view/mutations.test.ts +++ b/src/main/github/project-view/mutations.test.ts @@ -1,3 +1,4 @@ +vi.mock('../../git/github-cli-runner', () => ({ ghExecFileAsync: ghExecFileAsyncMock })) import { beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -46,7 +47,8 @@ vi.mock('./internals', () => ({ : { ok: false, error: { type: 'validation_error', message: `${name} invalid` } } })) -import { getWorkItemDetailsBySlug, updateIssueBySlug } from './mutations' +import { updateIssueBySlug } from './mutations' +import { getWorkItemDetailsBySlug } from './work-item-details-query' describe('updateIssueBySlug', () => { beforeEach(() => { diff --git a/src/main/github/project-view/mutations.ts b/src/main/github/project-view/mutations.ts index 8e06899b512..aac22ee361e 100644 --- a/src/main/github/project-view/mutations.ts +++ b/src/main/github/project-view/mutations.ts @@ -1,176 +1,24 @@ -/* eslint-disable max-lines -- Why: slug-addressed mutations + work-item -details share the validate/runRest/runGraphql plumbing with the read path. -Keeping them together preserves a single review surface for the write side. */ +import { ghExecFileAsync } from '../../git/github-cli-runner' import { acquire, - release, - extractExecError, - ghExecFileAsync, - repositoryRateLimitGuard, - noteRepositoryRateLimitSpend, - runGraphql, - runRest, - validateSlugArgs, assertPositiveInt, + extractExecError, + noteRepositoryRateLimitSpend, projectHostAuthenticationError, - projectGhExecOptions, - type GraphqlVars + release, + repositoryRateLimitGuard, + runRest, + validateSlugArgs } from './internals' import { classifyProjectError, rateLimitedError } from './project-error-classification' import { githubProjectHost } from '../../../shared/github/project-identity' -import type { PRComment } from '../../../shared/github/comment-types' -import type { GitHubAssignableUser } from '../../../shared/github/pull-request-types' -import type { GitHubWorkItemDetails } from '../../../shared/github/work-item-types' -import type { GitHubProjectFieldMutationValue } from '../../../shared/github/project-types' -import type { - GitHubProjectCommentMutationResult, - GitHubProjectMutationResult, - ListAssignableUsersBySlugResult, - ListIssueTypesBySlugResult, - ListLabelsBySlugResult, - ProjectWorkItemDetailsBySlugResult -} from '../../../shared/github/project-result-types' -import type { - AddIssueCommentBySlugArgs, - ClearProjectItemFieldArgs, - DeleteIssueCommentBySlugArgs, - ListAssignableUsersBySlugArgs, - ListIssueTypesBySlugArgs, - ListLabelsBySlugArgs, - ProjectWorkItemDetailsBySlugArgs, - UpdateIssueBySlugArgs, - UpdateIssueCommentBySlugArgs, - UpdateIssueTypeBySlugArgs, - UpdatePullRequestBySlugArgs, - UpdateProjectItemFieldArgs -} from '../../../shared/github/project-request-types' +import type { GitHubProjectMutationResult } from '../../../shared/github/project-result-types' +import type { UpdateIssueBySlugArgs } from '../../../shared/github/project-request-types' function githubHostExecOptions(args: { host?: string }): { host: string } { return { host: githubProjectHost(args.host) } } -// ─── Project field mutations ────────────────────────────────────────── - -class UnknownFieldMutationKindError extends Error { - constructor(kind: string) { - super(`Unknown project field mutation kind: ${kind}`) - } -} - -function graphqlValueForFieldMutation(value: GitHubProjectFieldMutationValue): string { - // Serialize the value fragment for the GraphQL mutation. We use GraphQL - // variables for every dynamic piece, so here we only pick the variable name - // to reference per value kind. - switch (value.kind) { - case 'single-select': - return 'singleSelectOptionId: $value' - case 'iteration': - return 'iterationId: $value' - case 'text': - return 'text: $value' - case 'number': - return 'number: $value' - case 'date': - return 'date: $value' - } - // Why: keep a runtime guard for malformed IPC payloads while lint enforces - // that every typed mutation kind is handled above. - throw new UnknownFieldMutationKindError((value as { kind: string }).kind) -} - -function mutationValueVar(value: GitHubProjectFieldMutationValue): { - type: string - val: string | number -} { - switch (value.kind) { - case 'single-select': - return { type: 'String!', val: value.optionId } - case 'iteration': - return { type: 'String!', val: value.iterationId } - case 'text': - return { type: 'String!', val: value.text } - case 'number': - return { type: 'Float!', val: value.number } - case 'date': - return { type: 'Date!', val: value.date } - } - // Why: see graphqlValueForFieldMutation — surface unknown kinds loudly - // instead of returning undefined and dispatching an invalid mutation. - throw new UnknownFieldMutationKindError((value as { kind: string }).kind) -} - -export async function updateProjectItemFieldValue( - args: UpdateProjectItemFieldArgs -): Promise { - if (!args.projectId || !args.itemId || !args.fieldId) { - return { ok: false, error: { type: 'validation_error', message: 'Missing ids.' } } - } - let valFrag: string - let valVar: { type: string; val: string | number } - try { - valFrag = graphqlValueForFieldMutation(args.value) - valVar = mutationValueVar(args.value) - } catch (err) { - if (err instanceof UnknownFieldMutationKindError) { - return { ok: false, error: { type: 'validation_error', message: err.message } } - } - throw err - } - const query = ` - mutation($projectId:ID!, $itemId:ID!, $fieldId:ID!, $value:${valVar.type}) { - updateProjectV2ItemFieldValue(input: { - projectId: $projectId - itemId: $itemId - fieldId: $fieldId - value: { ${valFrag} } - }) { projectV2Item { id } } - } - ` - const vars: GraphqlVars = { - projectId: args.projectId, - itemId: args.itemId, - fieldId: args.fieldId, - value: valVar.val - } - const res = await runGraphql(query, vars, projectGhExecOptions(args.host)) - if (!res.ok) { - return { ok: false, error: res.error } - } - return { ok: true } -} - -export async function clearProjectItemFieldValue( - args: ClearProjectItemFieldArgs -): Promise { - if (!args.projectId || !args.itemId || !args.fieldId) { - return { ok: false, error: { type: 'validation_error', message: 'Missing ids.' } } - } - const query = ` - mutation($projectId:ID!, $itemId:ID!, $fieldId:ID!) { - clearProjectV2ItemFieldValue(input: { - projectId: $projectId - itemId: $itemId - fieldId: $fieldId - }) { projectV2Item { id } } - } - ` - const res = await runGraphql( - query, - { - projectId: args.projectId, - itemId: args.itemId, - fieldId: args.fieldId - }, - projectGhExecOptions(args.host) - ) - if (!res.ok) { - return { ok: false, error: res.error } - } - return { ok: true } -} - -// ─── Slug-addressed issue/PR mutations ──────────────────────────────── - export async function updateIssueBySlug( args: UpdateIssueBySlugArgs ): Promise { @@ -374,573 +222,3 @@ export async function updateIssueBySlug( } return { ok: true } } - -export async function updatePullRequestBySlug( - args: UpdatePullRequestBySlugArgs -): Promise { - const v = validateSlugArgs(args.owner, args.repo) - if (!v.ok) { - return v - } - const n = assertPositiveInt(args.number, 'number') - if (!n.ok) { - return { ok: false, error: n.error } - } - if (!args.updates || typeof args.updates !== 'object') { - return { ok: false, error: { type: 'validation_error', message: 'Updates required.' } } - } - const patchArgs: string[] = [ - '-X', - 'PATCH', - `repos/${args.owner}/${args.repo}/pulls/${args.number}` - ] - // Why: count fields explicitly rather than inferring from patchArgs.length — - // adding a future header/flag arg silently breaks an array-length check. - let fieldCount = 0 - if (args.updates.title !== undefined) { - patchArgs.push('--raw-field', `title=${args.updates.title}`) - fieldCount++ - } - if (args.updates.body !== undefined) { - patchArgs.push('--raw-field', `body=${args.updates.body}`) - fieldCount++ - } - if (args.updates.state !== undefined) { - patchArgs.push('--raw-field', `state=${args.updates.state}`) - fieldCount++ - } - if (fieldCount === 0) { - // No fields to update — nothing to do. - return { ok: true } - } - const r = await runRest(patchArgs, undefined, 'core', githubHostExecOptions(args)) - if (!r.ok) { - return { ok: false, error: r.error } - } - return { ok: true } -} - -type RawIssueCommentResponse = { - id?: number - user?: { login?: string; avatar_url?: string; type?: string } | null - body?: string - created_at?: string - html_url?: string -} - -function mapIssueComment(data: RawIssueCommentResponse, fallbackBody: string): PRComment { - return { - id: data.id ?? Date.now(), - author: data.user?.login ?? 'You', - authorAvatarUrl: data.user?.avatar_url ?? '', - body: data.body ?? fallbackBody, - createdAt: data.created_at ?? new Date().toISOString(), - url: data.html_url ?? '', - isBot: data.user?.type === 'Bot' - } -} - -export async function addIssueCommentBySlug( - args: AddIssueCommentBySlugArgs -): Promise { - const v = validateSlugArgs(args.owner, args.repo) - if (!v.ok) { - return v - } - const n = assertPositiveInt(args.number, 'number') - if (!n.ok) { - return { ok: false, error: n.error } - } - if (typeof args.body !== 'string' || !args.body.trim()) { - return { ok: false, error: { type: 'validation_error', message: 'Comment body required.' } } - } - const r = await runRest( - [ - '-X', - 'POST', - `repos/${args.owner}/${args.repo}/issues/${args.number}/comments`, - '--raw-field', - `body=${args.body}` - ], - undefined, - 'core', - githubHostExecOptions(args) - ) - if (!r.ok) { - return { ok: false, error: r.error } - } - return { ok: true, comment: mapIssueComment(r.data, args.body) } -} - -export async function updateIssueCommentBySlug( - args: UpdateIssueCommentBySlugArgs -): Promise { - const v = validateSlugArgs(args.owner, args.repo) - if (!v.ok) { - return v - } - const n = assertPositiveInt(args.commentId, 'commentId') - if (!n.ok) { - return { ok: false, error: n.error } - } - if (typeof args.body !== 'string' || !args.body.trim()) { - return { ok: false, error: { type: 'validation_error', message: 'Comment body required.' } } - } - const r = await runRest( - [ - '-X', - 'PATCH', - `repos/${args.owner}/${args.repo}/issues/comments/${args.commentId}`, - '--raw-field', - `body=${args.body}` - ], - undefined, - 'core', - githubHostExecOptions(args) - ) - if (!r.ok) { - return { ok: false, error: r.error } - } - return { ok: true } -} - -export async function deleteIssueCommentBySlug( - args: DeleteIssueCommentBySlugArgs -): Promise { - const v = validateSlugArgs(args.owner, args.repo) - if (!v.ok) { - return v - } - const n = assertPositiveInt(args.commentId, 'commentId') - if (!n.ok) { - return { ok: false, error: n.error } - } - const r = await runRest( - ['-X', 'DELETE', `repos/${args.owner}/${args.repo}/issues/comments/${args.commentId}`], - undefined, - 'core', - { expectEmpty: true, ...githubHostExecOptions(args) } - ) - if (!r.ok) { - return { ok: false, error: r.error } - } - return { ok: true } -} - -// ─── Slug-addressed picker sources ──────────────────────────────────── - -export async function listLabelsBySlug( - args: ListLabelsBySlugArgs -): Promise { - const v = validateSlugArgs(args.owner, args.repo) - if (!v.ok) { - return v - } - const authError = await projectHostAuthenticationError(args.host) - if (authError) { - return { ok: false, error: authError } - } - const guard = repositoryRateLimitGuard(args, 'core') - if (guard.blocked) { - return { ok: false, error: rateLimitedError(guard) } - } - await acquire() - // Why: `--paginate` may fan out to multiple pages; we can only reasonably - // estimate a 1-call spend up front. The next probe will reconcile. - noteRepositoryRateLimitSpend(args, 'core') - try { - const { stdout } = await ghExecFileAsync( - ['api', '--paginate', `repos/${args.owner}/${args.repo}/labels`, '--jq', '.[].name'], - { encoding: 'utf-8', ...githubHostExecOptions(args) } - ) - return { - ok: true, - labels: stdout - .trim() - .split('\n') - .filter((l) => l.length > 0) - } - } catch (err) { - const { stderr, stdout: maybeStdout } = extractExecError(err) - return { ok: false, error: classifyProjectError(stderr, maybeStdout, args.host) } - } finally { - release() - } -} - -export async function listAssignableUsersBySlug( - args: ListAssignableUsersBySlugArgs -): Promise { - const v = validateSlugArgs(args.owner, args.repo) - if (!v.ok) { - return v - } - const authError = await projectHostAuthenticationError(args.host) - if (authError) { - return { ok: false, error: authError } - } - // Seed logins merge after the fetch so callers can include currently-visible - // assignees even if the repo participant search is sparse. - const result: GitHubAssignableUser[] = [] - const guard = repositoryRateLimitGuard(args, 'core') - if (guard.blocked) { - return { ok: false, error: rateLimitedError(guard) } - } - await acquire() - noteRepositoryRateLimitSpend(args, 'core') - try { - const { stdout } = await ghExecFileAsync( - [ - 'api', - '--paginate', - `repos/${args.owner}/${args.repo}/assignees`, - '--jq', - '.[] | {login: .login, name: null, avatarUrl: .avatar_url}' - ], - { encoding: 'utf-8', ...githubHostExecOptions(args) } - ) - for (const line of stdout - .trim() - .split('\n') - .filter((l) => l.length > 0)) { - try { - const u = JSON.parse(line) as { login?: string; avatarUrl?: string; name?: string | null } - if (typeof u.login === 'string') { - result.push({ login: u.login, name: u.name ?? null, avatarUrl: u.avatarUrl ?? '' }) - } - } catch { - // skip malformed jq line - } - } - } catch (err) { - const { stderr } = extractExecError(err) - return { ok: false, error: classifyProjectError(stderr, '', args.host) } - } finally { - release() - } - if (args.seedLogins) { - const seen = new Set(result.map((u) => u.login)) - for (const login of args.seedLogins) { - if (typeof login === 'string' && !seen.has(login)) { - result.push({ login, name: null, avatarUrl: '' }) - seen.add(login) - } - } - } - return { ok: true, users: result } -} - -// Why: Issue Types are a repo-level taxonomy (Bug/Feature/Task/etc) only -// available on repos opted into typed-issues. Empty list (or schema_drift on -// older GitHub deployments) is the legitimate "this repo doesn't use issue -// types" signal — callers should treat it as "no editor". -export async function listIssueTypesBySlug( - args: ListIssueTypesBySlugArgs -): Promise { - const v = validateSlugArgs(args.owner, args.repo) - if (!v.ok) { - return v - } - const query = ` - query($owner:String!, $repo:String!) { - repository(owner:$owner, name:$repo) { - issueTypes(first:50) { - nodes { id name color description } - } - } - } - ` - const res = await runGraphql<{ - repository?: { - issueTypes?: { - nodes?: ({ - id?: string - name?: string - color?: string | null - description?: string | null - } | null)[] - } | null - } | null - }>(query, { owner: args.owner, repo: args.repo }, githubHostExecOptions(args)) - if (!res.ok) { - // Why: repos without issue types respond with a GraphQL error claiming the - // `issueTypes` field is unknown. Map that to an empty list so the UI shows - // "no editor" instead of an angry banner. - if (res.error.type === 'schema_drift' || res.error.type === 'validation_error') { - return { ok: true, types: [] } - } - return { ok: false, error: res.error } - } - const nodes = res.data.repository?.issueTypes?.nodes ?? [] - const types = nodes - .filter( - (n): n is NonNullable => - n !== null && typeof n.id === 'string' && typeof n.name === 'string' - ) - .map((n) => ({ - id: n.id as string, - name: n.name as string, - color: typeof n.color === 'string' ? n.color : null, - description: typeof n.description === 'string' ? n.description : null - })) - return { ok: true, types } -} - -export async function updateIssueTypeBySlug( - args: UpdateIssueTypeBySlugArgs -): Promise { - const v = validateSlugArgs(args.owner, args.repo) - if (!v.ok) { - return v - } - const n = assertPositiveInt(args.number, 'number') - if (!n.ok) { - return { ok: false, error: n.error } - } - // Why: `updateIssueIssueType` is the dedicated mutation; passing null for - // `issueTypeId` clears the type. We resolve the issue id via a lightweight - // GraphQL lookup because the REST endpoint doesn't accept issue types. - const lookup = await runGraphql<{ - repository?: { issue?: { id?: string } | null } | null - }>( - `query($owner:String!, $repo:String!, $num:Int!) { - repository(owner:$owner, name:$repo) { issue(number:$num) { id } } - }`, - { owner: args.owner, repo: args.repo, num: args.number }, - githubHostExecOptions(args) - ) - if (!lookup.ok) { - return { ok: false, error: lookup.error } - } - const issueId = lookup.data.repository?.issue?.id - if (!issueId) { - return { ok: false, error: { type: 'not_found', message: 'Issue not found.' } } - } - // Why: build the mutation conditionally so a null clear doesn't have to - // smuggle a null GraphQL variable through `gh api graphql -f`. The - // mutation accepts a literal `null` in the input object directly. - const query = args.issueTypeId - ? ` - mutation($issueId:ID!, $issueTypeId:ID!) { - updateIssueIssueType(input: { issueId: $issueId, issueTypeId: $issueTypeId }) { - issue { id } - } - } - ` - : ` - mutation($issueId:ID!) { - updateIssueIssueType(input: { issueId: $issueId, issueTypeId: null }) { - issue { id } - } - } - ` - const vars: GraphqlVars = args.issueTypeId - ? { issueId, issueTypeId: args.issueTypeId } - : { issueId } - const res = await runGraphql(query, vars, githubHostExecOptions(args)) - if (!res.ok) { - return { ok: false, error: res.error } - } - return { ok: true } -} - -// ─── Slug-addressed work-item details ───────────────────────────────── - -type RawUser = { login?: string; name?: string | null; avatarUrl?: string | null } -type RawLabel = { name?: string; color?: string } -type RawWorkItemContent = { - id?: string - number?: number - title?: string - url?: string - state?: string - stateReason?: string | null - isDraft?: boolean - labels?: { nodes?: RawLabel[] } - assignees?: { nodes?: RawUser[] } -} - -export async function getWorkItemDetailsBySlug( - args: ProjectWorkItemDetailsBySlugArgs -): Promise { - const v = validateSlugArgs(args.owner, args.repo) - if (!v.ok) { - return v - } - const n = assertPositiveInt(args.number, 'number') - if (!n.ok) { - return { ok: false, error: n.error } - } - if (args.type !== 'issue' && args.type !== 'pr') { - return { ok: false, error: { type: 'validation_error', message: 'Invalid type.' } } - } - - // Single GraphQL round-trip to fetch the issue/PR summary + comments + labels + assignees. - const contentFrag = - args.type === 'issue' - ? ` - issue(number:$num) { - id number title url state stateReason updatedAt - body - author { login } - labels(first:50) { nodes { name } } - assignees(first:50) { nodes { login } } - participants(first:50) { nodes { login name avatarUrl } } - comments(first:100) { - nodes { - databaseId - author { login avatarUrl __typename } - body createdAt url - } - } - } - ` - : ` - pullRequest(number:$num) { - id number title url state isDraft updatedAt headRefName baseRefName - body - author { login } - labels(first:50) { nodes { name } } - assignees(first:50) { nodes { login } } - participants(first:50) { nodes { login name avatarUrl } } - comments(first:100) { - nodes { - databaseId - author { login avatarUrl __typename } - body createdAt url - } - } - } - ` - const query = ` - query($owner:String!, $repo:String!, $num:Int!) { - repository(owner:$owner, name:$repo) { - ${contentFrag} - } - } - ` - const res = await runGraphql<{ - repository?: { - issue?: - | (RawWorkItemContent & { - updatedAt?: string - body?: string - author?: { login?: string } | null - participants?: { nodes?: RawUser[] } - comments?: { - nodes?: ({ - databaseId?: number - author?: { login?: string; avatarUrl?: string; __typename?: string } | null - body?: string - createdAt?: string - url?: string - } | null)[] - } - }) - | null - pullRequest?: - | (RawWorkItemContent & { - updatedAt?: string - body?: string - headRefName?: string - baseRefName?: string - author?: { login?: string } | null - participants?: { nodes?: RawUser[] } - comments?: { - nodes?: ({ - databaseId?: number - author?: { login?: string; avatarUrl?: string; __typename?: string } | null - body?: string - createdAt?: string - url?: string - } | null)[] - } - }) - | null - } | null - }>(query, { owner: args.owner, repo: args.repo, num: args.number }, githubHostExecOptions(args)) - if (!res.ok) { - return { ok: false, error: res.error } - } - const raw = args.type === 'issue' ? res.data.repository?.issue : res.data.repository?.pullRequest - if (!raw) { - return { ok: false, error: { type: 'not_found', message: 'Item not found.' } } - } - - const labels = (raw.labels?.nodes ?? []) - .map((l) => l?.name) - .filter((n): n is string => typeof n === 'string') - const assignees = (raw.assignees?.nodes ?? []) - .map((a) => a?.login) - .filter((l): l is string => typeof l === 'string') - const comments: PRComment[] = [] - for (const c of raw.comments?.nodes ?? []) { - if (!c || typeof c.body !== 'string') { - continue - } - comments.push({ - id: typeof c.databaseId === 'number' ? c.databaseId : Date.now(), - author: c.author?.login ?? '', - authorAvatarUrl: c.author?.avatarUrl ?? '', - body: c.body, - createdAt: typeof c.createdAt === 'string' ? c.createdAt : '', - url: typeof c.url === 'string' ? c.url : '', - isBot: c.author?.__typename === 'Bot' - }) - } - const participants: GitHubAssignableUser[] = [] - for (const p of raw.participants?.nodes ?? []) { - if (p && typeof p.login === 'string') { - participants.push({ login: p.login, name: p.name ?? null, avatarUrl: p.avatarUrl ?? '' }) - } - } - - const state: 'open' | 'closed' | 'merged' | 'draft' = - args.type === 'pr' - ? raw.isDraft - ? 'draft' - : raw.state === 'MERGED' - ? 'merged' - : raw.state === 'CLOSED' - ? 'closed' - : 'open' - : raw.state === 'CLOSED' - ? 'closed' - : 'open' - - const details: GitHubWorkItemDetails = { - item: { - id: typeof raw.id === 'string' ? raw.id : '', - type: args.type, - number: typeof raw.number === 'number' ? raw.number : args.number, - title: typeof raw.title === 'string' ? raw.title : '', - state, - url: typeof raw.url === 'string' ? raw.url : '', - labels, - updatedAt: - typeof (raw as { updatedAt?: string }).updatedAt === 'string' - ? (raw as { updatedAt: string }).updatedAt - : '', - author: - typeof (raw as { author?: { login?: string } | null }).author?.login === 'string' - ? ((raw as { author: { login: string } }).author.login as string) - : null, - branchName: - args.type === 'pr' && typeof (raw as { headRefName?: string }).headRefName === 'string' - ? ((raw as { headRefName: string }).headRefName as string) - : undefined, - baseRefName: - args.type === 'pr' && typeof (raw as { baseRefName?: string }).baseRefName === 'string' - ? ((raw as { baseRefName: string }).baseRefName as string) - : undefined - }, - body: typeof raw.body === 'string' ? raw.body : '', - comments, - participants, - // Why: PR files/checks/review-thread tabs depend on a local repo path and - // are out of Project-mode slug scope for v1. Omit them here; the dialog - // branches on their absence and hides those tabs. - assignees - } - return { ok: true, details } -} diff --git a/src/main/github/project-view/project-item-field-mutations.ts b/src/main/github/project-view/project-item-field-mutations.ts new file mode 100644 index 00000000000..f8c2fb512e5 --- /dev/null +++ b/src/main/github/project-view/project-item-field-mutations.ts @@ -0,0 +1,125 @@ +import { projectGhExecOptions, runGraphql, type GraphqlVars } from './internals' +import type { GitHubProjectFieldMutationValue } from '../../../shared/github/project-types' +import type { GitHubProjectMutationResult } from '../../../shared/github/project-result-types' +import type { + ClearProjectItemFieldArgs, + UpdateProjectItemFieldArgs +} from '../../../shared/github/project-request-types' + +class UnknownFieldMutationKindError extends Error { + constructor(kind: string) { + super(`Unknown project field mutation kind: ${kind}`) + } +} + +function graphqlValueForFieldMutation(value: GitHubProjectFieldMutationValue): string { + // Serialize the value fragment for the GraphQL mutation. We use GraphQL + // variables for every dynamic piece, so here we only pick the variable name + // to reference per value kind. + switch (value.kind) { + case 'single-select': + return 'singleSelectOptionId: $value' + case 'iteration': + return 'iterationId: $value' + case 'text': + return 'text: $value' + case 'number': + return 'number: $value' + case 'date': + return 'date: $value' + } + // Why: keep a runtime guard for malformed IPC payloads while lint enforces + // that every typed mutation kind is handled above. + throw new UnknownFieldMutationKindError((value as { kind: string }).kind) +} + +function mutationValueVar(value: GitHubProjectFieldMutationValue): { + type: string + val: string | number +} { + switch (value.kind) { + case 'single-select': + return { type: 'String!', val: value.optionId } + case 'iteration': + return { type: 'String!', val: value.iterationId } + case 'text': + return { type: 'String!', val: value.text } + case 'number': + return { type: 'Float!', val: value.number } + case 'date': + return { type: 'Date!', val: value.date } + } + // Why: see graphqlValueForFieldMutation — surface unknown kinds loudly + // instead of returning undefined and dispatching an invalid mutation. + throw new UnknownFieldMutationKindError((value as { kind: string }).kind) +} + +export async function updateProjectItemFieldValue( + args: UpdateProjectItemFieldArgs +): Promise { + if (!args.projectId || !args.itemId || !args.fieldId) { + return { ok: false, error: { type: 'validation_error', message: 'Missing ids.' } } + } + let valFrag: string + let valVar: { type: string; val: string | number } + try { + valFrag = graphqlValueForFieldMutation(args.value) + valVar = mutationValueVar(args.value) + } catch (err) { + if (err instanceof UnknownFieldMutationKindError) { + return { ok: false, error: { type: 'validation_error', message: err.message } } + } + throw err + } + const query = ` + mutation($projectId:ID!, $itemId:ID!, $fieldId:ID!, $value:${valVar.type}) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId + itemId: $itemId + fieldId: $fieldId + value: { ${valFrag} } + }) { projectV2Item { id } } + } + ` + const vars: GraphqlVars = { + projectId: args.projectId, + itemId: args.itemId, + fieldId: args.fieldId, + value: valVar.val + } + const res = await runGraphql(query, vars, projectGhExecOptions(args.host)) + if (!res.ok) { + return { ok: false, error: res.error } + } + return { ok: true } +} + +export async function clearProjectItemFieldValue( + args: ClearProjectItemFieldArgs +): Promise { + if (!args.projectId || !args.itemId || !args.fieldId) { + return { ok: false, error: { type: 'validation_error', message: 'Missing ids.' } } + } + const query = ` + mutation($projectId:ID!, $itemId:ID!, $fieldId:ID!) { + clearProjectV2ItemFieldValue(input: { + projectId: $projectId + itemId: $itemId + fieldId: $fieldId + }) { projectV2Item { id } } + } + ` + const res = await runGraphql( + query, + { + projectId: args.projectId, + itemId: args.itemId, + fieldId: args.fieldId + }, + projectGhExecOptions(args.host) + ) + if (!res.ok) { + return { ok: false, error: res.error } + } + return { ok: true } +} diff --git a/src/main/github/project-view/project-reference.ts b/src/main/github/project-view/project-reference.ts new file mode 100644 index 00000000000..3ae51d615da --- /dev/null +++ b/src/main/github/project-view/project-reference.ts @@ -0,0 +1,212 @@ +import type { GitHubProjectOwnerType } from '../../../shared/github/project-types' +import type { + GitHubProjectViewError, + ResolveProjectRefResult +} from '../../../shared/github/project-result-types' +import type { ResolveProjectRefArgs } from '../../../shared/github/project-request-types' +import { + GITHUB_PROJECT_REF_INPUT_TOO_LARGE_ERROR, + isGitHubProjectRefInputTooLarge +} from '../../../shared/github/project-ref-input' +import { githubProjectHost } from '../../../shared/github/project-identity' +import { getCachedOwnerType, rememberOwnerType } from './cache-state' +import { isValidOwnerSlug, projectGhExecOptions, runGraphql, type GraphqlVars } from './internals' +import { ownerQueryRoot } from './view-configuration-query' + +export type ParsedProjectPaste = + | { kind: 'org'; owner: string; number: number; host: string; viewNumber?: number } + | { kind: 'user'; owner: string; number: number; host: string; viewNumber?: number } + | { kind: 'bare'; owner: string; number: number } + +export function parseProjectPaste(input: string, host?: string): ParsedProjectPaste | null { + const trimmed = input.trim() + if (!trimmed || isGitHubProjectRefInputTooLarge(trimmed)) { + return null + } + // URL parsing enforces an exact Project path and rejects credentials. + try { + const url = new URL(trimmed) + const allowedHosts = new Set(['github.com', ...(host ? [host.trim().toLowerCase()] : [])]) + const parts = url.pathname.split('/').filter(Boolean) + const hasView = parts.length === 6 && parts[4] === 'views' + if ( + (url.protocol !== 'https:' && url.protocol !== 'http:') || + url.username || + url.password || + !allowedHosts.has(url.host.toLowerCase()) || + (parts[0] !== 'orgs' && parts[0] !== 'users') || + !isValidOwnerSlug(parts[1]) || + parts[2] !== 'projects' || + (parts.length !== 4 && !hasView) + ) { + return null + } + const number = Number(parts[3]) + const viewNumber = hasView ? Number(parts[5]) : undefined + if ( + !Number.isSafeInteger(number) || + number < 1 || + (hasView && (!Number.isSafeInteger(viewNumber) || (viewNumber ?? 0) < 1)) + ) { + return null + } + return { + kind: parts[0] === 'orgs' ? 'org' : 'user', + owner: parts[1], + number, + host: url.host.toLowerCase(), + ...(viewNumber !== undefined ? { viewNumber } : {}) + } + } catch { + // Shorthand parsing remains available for non-URL input. + } + const shorthand = trimmed.match(/^([A-Za-z0-9][A-Za-z0-9-]*)\/(\d+)$/) + if (!shorthand) { + return null + } + const number = Number.parseInt(shorthand[2], 10) + if (!Number.isInteger(number) || number < 1) { + return null + } + return { kind: 'bare', owner: shorthand[1], number } +} + +async function resolveOwnerType( + owner: string, + preferred: GitHubProjectOwnerType | null, + host?: string +): Promise< + | { ok: true; ownerType: GitHubProjectOwnerType; title: string } + | { ok: false; error: GitHubProjectViewError } +> { + const tryOne = async ( + ownerType: GitHubProjectOwnerType, + number: number | null + ): Promise<{ ok: true; title: string } | { ok: false; error: GitHubProjectViewError }> => { + const root = ownerQueryRoot(ownerType) + const query = number + ? ` + query($owner:String!, $num:Int!) { + ${root}(login:$owner) { projectV2(number:$num) { id title } } + } + ` + : ` + query($owner:String!) { + ${root}(login:$owner) { login } + } + ` + const vars: GraphqlVars = { owner } + if (number) { + vars.num = number + } + const result = await runGraphql< + Record + >(query, vars, projectGhExecOptions(host)) + if (!result.ok) { + return { ok: false, error: result.error } + } + const top = result.data[root] + if (!top) { + return { ok: false, error: { type: 'not_found', message: 'Owner not found.' } } + } + if (number) { + const project = top.projectV2 + if (!project || typeof project.id !== 'string') { + return { ok: false, error: { type: 'not_found', message: 'Project not found.' } } + } + return { ok: true, title: project.title ?? '' } + } + return { ok: true, title: '' } + } + + const cached = getCachedOwnerType(owner, host) + const candidates: GitHubProjectOwnerType[] = preferred + ? [preferred] + : cached + ? [cached] + : ['organization', 'user'] + const fallback: GitHubProjectOwnerType[] = preferred + ? [] + : cached + ? cached === 'organization' + ? ['user'] + : ['organization'] + : [] + let lastError: GitHubProjectViewError | null = null + for (const ownerType of [...candidates, ...fallback]) { + const result = await tryOne(ownerType, null) + if (result.ok) { + rememberOwnerType(owner, ownerType, host) + return { ok: true, ownerType, title: result.title } + } + lastError = result.error + if (result.error.type !== 'not_found') { + return { ok: false, error: result.error } + } + } + rememberOwnerType(owner, null, host) + return { + ok: false, + error: lastError ?? { type: 'not_found', message: 'Owner not found.' } + } +} + +export async function resolveProjectRef( + args: ResolveProjectRefArgs +): Promise { + const input = typeof args.input === 'string' ? args.input.trim() : '' + if (!input) { + return { ok: false, error: { type: 'validation_error', message: 'Input required.' } } + } + if (isGitHubProjectRefInputTooLarge(input)) { + return { + ok: false, + error: { type: 'validation_error', message: GITHUB_PROJECT_REF_INPUT_TOO_LARGE_ERROR } + } + } + const parsed = parseProjectPaste(input, args.host) + if (!parsed) { + return { + ok: false, + error: { + type: 'validation_error', + message: 'Could not parse input. Expected a GitHub project URL or `owner/number`.' + } + } + } + const preferred: GitHubProjectOwnerType | null = + parsed.kind === 'org' ? 'organization' : parsed.kind === 'user' ? 'user' : null + // A pasted URL is authoritative; the ambient host only applies to shorthand. + const executionHost = parsed.kind === 'bare' ? githubProjectHost(args.host) : parsed.host + const ownerResult = await resolveOwnerType(parsed.owner, preferred, executionHost) + if (!ownerResult.ok) { + return { ok: false, error: ownerResult.error } + } + const root = ownerQueryRoot(ownerResult.ownerType) + const query = ` + query($owner:String!, $num:Int!) { + ${root}(login:$owner) { projectV2(number:$num) { id title } } + } + ` + const result = await runGraphql< + Record + >(query, { owner: parsed.owner, num: parsed.number }, projectGhExecOptions(executionHost)) + if (!result.ok) { + return { ok: false, error: result.error } + } + const project = result.data[root]?.projectV2 + if (!project || typeof project.id !== 'string') { + return { ok: false, error: { type: 'not_found', message: 'Project not found.' } } + } + return { + ok: true, + owner: parsed.owner, + ownerType: ownerResult.ownerType, + number: parsed.number, + title: project.title ?? '', + host: executionHost, + ...(parsed.kind !== 'bare' && parsed.viewNumber !== undefined + ? { viewNumber: parsed.viewNumber } + : {}) + } +} diff --git a/src/main/github/project-view/pull-request-mutations.ts b/src/main/github/project-view/pull-request-mutations.ts new file mode 100644 index 00000000000..44d8a46b85e --- /dev/null +++ b/src/main/github/project-view/pull-request-mutations.ts @@ -0,0 +1,48 @@ +import { assertPositiveInt, projectGhExecOptions, runRest, validateSlugArgs } from './internals' +import type { GitHubProjectMutationResult } from '../../../shared/github/project-result-types' +import type { UpdatePullRequestBySlugArgs } from '../../../shared/github/project-request-types' + +export async function updatePullRequestBySlug( + args: UpdatePullRequestBySlugArgs +): Promise { + const v = validateSlugArgs(args.owner, args.repo) + if (!v.ok) { + return v + } + const n = assertPositiveInt(args.number, 'number') + if (!n.ok) { + return { ok: false, error: n.error } + } + if (!args.updates || typeof args.updates !== 'object') { + return { ok: false, error: { type: 'validation_error', message: 'Updates required.' } } + } + const patchArgs: string[] = [ + '-X', + 'PATCH', + `repos/${args.owner}/${args.repo}/pulls/${args.number}` + ] + // Why: count fields explicitly rather than inferring from patchArgs.length — + // adding a future header/flag arg silently breaks an array-length check. + let fieldCount = 0 + if (args.updates.title !== undefined) { + patchArgs.push('--raw-field', `title=${args.updates.title}`) + fieldCount++ + } + if (args.updates.body !== undefined) { + patchArgs.push('--raw-field', `body=${args.updates.body}`) + fieldCount++ + } + if (args.updates.state !== undefined) { + patchArgs.push('--raw-field', `state=${args.updates.state}`) + fieldCount++ + } + if (fieldCount === 0) { + // No fields to update — nothing to do. + return { ok: true } + } + const r = await runRest(patchArgs, undefined, 'core', projectGhExecOptions(args.host)) + if (!r.ok) { + return { ok: false, error: r.error } + } + return { ok: true } +} diff --git a/src/main/github/project-view/repository-issue-types.ts b/src/main/github/project-view/repository-issue-types.ts new file mode 100644 index 00000000000..e55612c6de2 --- /dev/null +++ b/src/main/github/project-view/repository-issue-types.ts @@ -0,0 +1,129 @@ +import { + assertPositiveInt, + projectGhExecOptions, + runGraphql, + validateSlugArgs, + type GraphqlVars +} from './internals' +import type { + GitHubProjectMutationResult, + ListIssueTypesBySlugResult +} from '../../../shared/github/project-result-types' +import type { + ListIssueTypesBySlugArgs, + UpdateIssueTypeBySlugArgs +} from '../../../shared/github/project-request-types' + +// Why: Issue Types are a repo-level taxonomy (Bug/Feature/Task/etc) only +// available on repos opted into typed-issues. Empty list (or schema_drift on +// older GitHub deployments) is the legitimate "this repo doesn't use issue +// types" signal — callers should treat it as "no editor". +export async function listIssueTypesBySlug( + args: ListIssueTypesBySlugArgs +): Promise { + const v = validateSlugArgs(args.owner, args.repo) + if (!v.ok) { + return v + } + const query = ` + query($owner:String!, $repo:String!) { + repository(owner:$owner, name:$repo) { + issueTypes(first:50) { + nodes { id name color description } + } + } + } + ` + const res = await runGraphql<{ + repository?: { + issueTypes?: { + nodes?: ({ + id?: string + name?: string + color?: string | null + description?: string | null + } | null)[] + } | null + } | null + }>(query, { owner: args.owner, repo: args.repo }, projectGhExecOptions(args.host)) + if (!res.ok) { + // Why: repos without issue types respond with a GraphQL error claiming the + // `issueTypes` field is unknown. Map that to an empty list so the UI shows + // "no editor" instead of an angry banner. + if (res.error.type === 'schema_drift' || res.error.type === 'validation_error') { + return { ok: true, types: [] } + } + return { ok: false, error: res.error } + } + const nodes = res.data.repository?.issueTypes?.nodes ?? [] + const types = nodes + .filter( + (n): n is NonNullable => + n !== null && typeof n.id === 'string' && typeof n.name === 'string' + ) + .map((n) => ({ + id: n.id as string, + name: n.name as string, + color: typeof n.color === 'string' ? n.color : null, + description: typeof n.description === 'string' ? n.description : null + })) + return { ok: true, types } +} + +export async function updateIssueTypeBySlug( + args: UpdateIssueTypeBySlugArgs +): Promise { + const v = validateSlugArgs(args.owner, args.repo) + if (!v.ok) { + return v + } + const n = assertPositiveInt(args.number, 'number') + if (!n.ok) { + return { ok: false, error: n.error } + } + // Why: `updateIssueIssueType` is the dedicated mutation; passing null for + // `issueTypeId` clears the type. We resolve the issue id via a lightweight + // GraphQL lookup because the REST endpoint doesn't accept issue types. + const lookup = await runGraphql<{ + repository?: { issue?: { id?: string } | null } | null + }>( + `query($owner:String!, $repo:String!, $num:Int!) { + repository(owner:$owner, name:$repo) { issue(number:$num) { id } } + }`, + { owner: args.owner, repo: args.repo, num: args.number }, + projectGhExecOptions(args.host) + ) + if (!lookup.ok) { + return { ok: false, error: lookup.error } + } + const issueId = lookup.data.repository?.issue?.id + if (!issueId) { + return { ok: false, error: { type: 'not_found', message: 'Issue not found.' } } + } + // Why: build the mutation conditionally so a null clear doesn't have to + // smuggle a null GraphQL variable through `gh api graphql -f`. The + // mutation accepts a literal `null` in the input object directly. + const query = args.issueTypeId + ? ` + mutation($issueId:ID!, $issueTypeId:ID!) { + updateIssueIssueType(input: { issueId: $issueId, issueTypeId: $issueTypeId }) { + issue { id } + } + } + ` + : ` + mutation($issueId:ID!) { + updateIssueIssueType(input: { issueId: $issueId, issueTypeId: null }) { + issue { id } + } + } + ` + const vars: GraphqlVars = args.issueTypeId + ? { issueId, issueTypeId: args.issueTypeId } + : { issueId } + const res = await runGraphql(query, vars, projectGhExecOptions(args.host)) + if (!res.ok) { + return { ok: false, error: res.error } + } + return { ok: true } +} diff --git a/src/main/github/project-view/repository-label-assignee-sources.ts b/src/main/github/project-view/repository-label-assignee-sources.ts new file mode 100644 index 00000000000..251105d6a81 --- /dev/null +++ b/src/main/github/project-view/repository-label-assignee-sources.ts @@ -0,0 +1,122 @@ +import { ghExecFileAsync } from '../../git/github-cli-runner' +import { + acquire, + extractExecError, + noteRepositoryRateLimitSpend, + projectGhExecOptions, + projectHostAuthenticationError, + release, + repositoryRateLimitGuard, + validateSlugArgs +} from './internals' +import { classifyProjectError, rateLimitedError } from './project-error-classification' +import type { GitHubAssignableUser } from '../../../shared/github/pull-request-types' +import type { + ListAssignableUsersBySlugResult, + ListLabelsBySlugResult +} from '../../../shared/github/project-result-types' +import type { + ListAssignableUsersBySlugArgs, + ListLabelsBySlugArgs +} from '../../../shared/github/project-request-types' + +export async function listLabelsBySlug( + args: ListLabelsBySlugArgs +): Promise { + const v = validateSlugArgs(args.owner, args.repo) + if (!v.ok) { + return v + } + const authError = await projectHostAuthenticationError(args.host) + if (authError) { + return { ok: false, error: authError } + } + const guard = repositoryRateLimitGuard(args, 'core') + if (guard.blocked) { + return { ok: false, error: rateLimitedError(guard) } + } + await acquire() + // Why: `--paginate` may fan out to multiple pages; we can only reasonably + // estimate a 1-call spend up front. The next probe will reconcile. + noteRepositoryRateLimitSpend(args, 'core') + try { + const { stdout } = await ghExecFileAsync( + ['api', '--paginate', `repos/${args.owner}/${args.repo}/labels`, '--jq', '.[].name'], + { encoding: 'utf-8', ...projectGhExecOptions(args.host) } + ) + return { + ok: true, + labels: stdout + .trim() + .split('\n') + .filter((l) => l.length > 0) + } + } catch (err) { + const { stderr, stdout: maybeStdout } = extractExecError(err) + return { ok: false, error: classifyProjectError(stderr, maybeStdout, args.host) } + } finally { + release() + } +} + +export async function listAssignableUsersBySlug( + args: ListAssignableUsersBySlugArgs +): Promise { + const v = validateSlugArgs(args.owner, args.repo) + if (!v.ok) { + return v + } + const authError = await projectHostAuthenticationError(args.host) + if (authError) { + return { ok: false, error: authError } + } + // Seed logins merge after the fetch so callers can include currently-visible + // assignees even if the repo participant search is sparse. + const result: GitHubAssignableUser[] = [] + const guard = repositoryRateLimitGuard(args, 'core') + if (guard.blocked) { + return { ok: false, error: rateLimitedError(guard) } + } + await acquire() + noteRepositoryRateLimitSpend(args, 'core') + try { + const { stdout } = await ghExecFileAsync( + [ + 'api', + '--paginate', + `repos/${args.owner}/${args.repo}/assignees`, + '--jq', + '.[] | {login: .login, name: null, avatarUrl: .avatar_url}' + ], + { encoding: 'utf-8', ...projectGhExecOptions(args.host) } + ) + for (const line of stdout + .trim() + .split('\n') + .filter((l) => l.length > 0)) { + try { + const u = JSON.parse(line) as { login?: string; avatarUrl?: string; name?: string | null } + if (typeof u.login === 'string') { + result.push({ login: u.login, name: u.name ?? null, avatarUrl: u.avatarUrl ?? '' }) + } + } catch { + // skip malformed jq line + } + } + } catch (err) { + const { stderr } = extractExecError(err) + return { ok: false, error: classifyProjectError(stderr, '', args.host) } + } finally { + release() + } + if (args.seedLogins) { + const seen = new Set(result.map((u) => u.login)) + for (const login of args.seedLogins) { + if (typeof login === 'string' && !seen.has(login)) { + result.push({ login, name: null, avatarUrl: '' }) + seen.add(login) + } + } + } + return { ok: true, users: result } +} diff --git a/src/main/github/project-view/response-normalization.ts b/src/main/github/project-view/response-normalization.ts new file mode 100644 index 00000000000..92bd9d6f46e --- /dev/null +++ b/src/main/github/project-view/response-normalization.ts @@ -0,0 +1,313 @@ +import type { + GitHubProjectField, + GitHubProjectFieldValue, + GitHubProjectIteration, + GitHubProjectLabel, + GitHubProjectRow, + GitHubProjectRowItemType, + GitHubProjectSingleSelectOption, + GitHubProjectUser +} from '../../../shared/github/project-types' +import type { GitHubProjectViewError } from '../../../shared/github/project-result-types' +import { driftError } from './project-error-classification' + +export type RawProjectV2Field = { + __typename?: string + id?: string + name?: string + dataType?: string + options?: { id?: string; name?: string; color?: string }[] + configuration?: { + iterations?: { id?: string; title?: string; startDate?: string; duration?: number }[] + completedIterations?: { + id?: string + title?: string + startDate?: string + duration?: number + }[] + } +} + +export function normalizeField( + raw: RawProjectV2Field | null | undefined +): GitHubProjectField | null { + if (!raw || typeof raw.id !== 'string' || typeof raw.name !== 'string') { + return null + } + const dataType = raw.dataType ?? raw.__typename ?? '' + if (raw.__typename === 'ProjectV2SingleSelectField' || dataType === 'SINGLE_SELECT') { + const options: GitHubProjectSingleSelectOption[] = (raw.options ?? []) + .map((option) => + typeof option.id === 'string' && typeof option.name === 'string' + ? { id: option.id, name: option.name, color: option.color ?? '' } + : null + ) + .filter((option): option is GitHubProjectSingleSelectOption => option !== null) + return { kind: 'single-select', id: raw.id, name: raw.name, dataType: 'SINGLE_SELECT', options } + } + if (raw.__typename === 'ProjectV2IterationField' || dataType === 'ITERATION') { + const configuration = raw.configuration ?? {} + const iterations: GitHubProjectIteration[] = [] + for (const iteration of configuration.completedIterations ?? []) { + if (typeof iteration.id === 'string' && typeof iteration.title === 'string') { + iterations.push({ + id: iteration.id, + title: iteration.title, + startDate: iteration.startDate ?? '', + duration: typeof iteration.duration === 'number' ? iteration.duration : 0, + completed: true + }) + } + } + for (const iteration of configuration.iterations ?? []) { + if (typeof iteration.id === 'string' && typeof iteration.title === 'string') { + iterations.push({ + id: iteration.id, + title: iteration.title, + startDate: iteration.startDate ?? '', + duration: typeof iteration.duration === 'number' ? iteration.duration : 0, + completed: false + }) + } + } + return { kind: 'iteration', id: raw.id, name: raw.name, dataType: 'ITERATION', iterations } + } + return { kind: 'field', id: raw.id, name: raw.name, dataType } +} + +type RawUser = { + login?: string + name?: string | null + avatarUrl?: string | null +} + +function normalizeUser(raw: RawUser | null | undefined): GitHubProjectUser | null { + if (!raw || typeof raw.login !== 'string') { + return null + } + return { login: raw.login, name: raw.name ?? null, avatarUrl: raw.avatarUrl ?? null } +} + +type RawLabel = { name?: string; color?: string } + +function normalizeLabel(raw: RawLabel | null | undefined): GitHubProjectLabel | null { + if (!raw || typeof raw.name !== 'string') { + return null + } + return { name: raw.name, color: raw.color ?? '' } +} + +type RawFieldValue = { + __typename?: string + field?: RawProjectV2Field + name?: string + color?: string + optionId?: string + title?: string + startDate?: string + duration?: number + iterationId?: string + text?: string + number?: number + date?: string + labels?: { nodes?: RawLabel[] } + users?: { nodes?: RawUser[] } +} + +export function normalizeFieldValue( + raw: RawFieldValue | null | undefined +): GitHubProjectFieldValue | null { + if (!raw || !raw.field || typeof raw.field.id !== 'string') { + return null + } + const fieldId = raw.field.id + if (typeof raw.__typename !== 'string') { + return null + } + switch (raw.__typename) { + case 'ProjectV2ItemFieldSingleSelectValue': + if (typeof raw.optionId !== 'string') { + return null + } + return { + kind: 'single-select', + fieldId, + optionId: raw.optionId, + name: raw.name ?? '', + color: raw.color ?? '' + } + case 'ProjectV2ItemFieldIterationValue': + if (typeof raw.iterationId !== 'string') { + return null + } + return { + kind: 'iteration', + fieldId, + iterationId: raw.iterationId, + title: raw.title ?? '', + startDate: raw.startDate ?? '', + duration: typeof raw.duration === 'number' ? raw.duration : 0 + } + case 'ProjectV2ItemFieldTextValue': + return { kind: 'text', fieldId, text: raw.text ?? '' } + case 'ProjectV2ItemFieldNumberValue': + if (typeof raw.number !== 'number') { + return null + } + return { kind: 'number', fieldId, number: raw.number } + case 'ProjectV2ItemFieldDateValue': + return { kind: 'date', fieldId, date: raw.date ?? '' } + case 'ProjectV2ItemFieldLabelValue': { + const labels = (raw.labels?.nodes ?? []) + .map(normalizeLabel) + .filter((label): label is GitHubProjectLabel => label !== null) + return { kind: 'labels', fieldId, labels } + } + case 'ProjectV2ItemFieldUserValue': { + const users = (raw.users?.nodes ?? []) + .map(normalizeUser) + .filter((user): user is GitHubProjectUser => user !== null) + return { kind: 'users', fieldId, users } + } + default: + // Unknown __typename is forward-compatible and intentionally dropped. + return null + } +} + +type RawContent = { + __typename?: string + id?: string + number?: number + title?: string + body?: string + url?: string + state?: string + stateReason?: string | null + isDraft?: boolean + repository?: { nameWithOwner?: string } + assignees?: { nodes?: RawUser[] } + labels?: { nodes?: RawLabel[] } + parent?: { number?: number; title?: string; url?: string } | null + issueType?: { + id?: string + name?: string + color?: string | null + description?: string | null + } | null +} + +export type RawProjectItem = { + id?: string + type?: string + updatedAt?: string + content?: RawContent | null + fieldValues?: { + nodes?: RawFieldValue[] + pageInfo?: { hasNextPage?: boolean } + } +} + +type NormalizedItemOutcome = + | { ok: true; row: GitHubProjectRow } + | { ok: false; drift: GitHubProjectViewError } + +function mapItemType(raw: string | undefined, hasContent: boolean): GitHubProjectRowItemType { + if (raw === 'ISSUE') { + return 'ISSUE' + } + if (raw === 'PULL_REQUEST') { + return 'PULL_REQUEST' + } + if (raw === 'DRAFT_ISSUE') { + return 'DRAFT_ISSUE' + } + if (raw === 'REDACTED' || !hasContent) { + return 'REDACTED' + } + return 'REDACTED' +} + +export function normalizeItem(raw: RawProjectItem, position: number): NormalizedItemOutcome { + if (!raw || typeof raw.id !== 'string') { + return { + ok: false, + drift: driftError('item missing id', { path: ['items', 'nodes', position, 'id'] }) + } + } + if (raw.fieldValues?.pageInfo?.hasNextPage === true) { + return { + ok: false, + drift: driftError('item field values exceeded single page', { + path: ['items', 'nodes', position, 'fieldValues', 'pageInfo', 'hasNextPage'] + }) + } + } + const itemType = mapItemType(raw.type, raw.content !== null && raw.content !== undefined) + const content = raw.content ?? null + const assignees = (content?.assignees?.nodes ?? []) + .map(normalizeUser) + .filter((user): user is GitHubProjectUser => user !== null) + const labels = (content?.labels?.nodes ?? []) + .map(normalizeLabel) + .filter((label): label is GitHubProjectLabel => label !== null) + const parentIssue = + content?.parent && + typeof content.parent.number === 'number' && + typeof content.parent.title === 'string' && + typeof content.parent.url === 'string' + ? { number: content.parent.number, title: content.parent.title, url: content.parent.url } + : null + const issueType = + content?.issueType && + typeof content.issueType.id === 'string' && + typeof content.issueType.name === 'string' + ? { + id: content.issueType.id, + name: content.issueType.name, + color: typeof content.issueType.color === 'string' ? content.issueType.color : null, + description: + typeof content.issueType.description === 'string' ? content.issueType.description : null + } + : null + const fieldValuesByFieldId: Record = {} + for (const fieldValue of raw.fieldValues?.nodes ?? []) { + const normalized = normalizeFieldValue(fieldValue) + if (normalized) { + fieldValuesByFieldId[normalized.fieldId] = normalized + } + } + const title = + itemType === 'REDACTED' + ? 'Restricted item' + : typeof content?.title === 'string' + ? content.title + : '' + return { + ok: true, + row: { + id: raw.id, + itemType, + content: { + number: typeof content?.number === 'number' ? content.number : null, + title, + body: typeof content?.body === 'string' ? content.body : null, + url: typeof content?.url === 'string' ? content.url : null, + state: typeof content?.state === 'string' ? content.state : null, + stateReason: typeof content?.stateReason === 'string' ? content.stateReason : null, + isDraft: typeof content?.isDraft === 'boolean' ? content.isDraft : null, + repository: + typeof content?.repository?.nameWithOwner === 'string' + ? content.repository.nameWithOwner + : null, + assignees, + labels, + parentIssue, + issueType + }, + fieldValuesByFieldId, + updatedAt: typeof raw.updatedAt === 'string' ? raw.updatedAt : '', + position + } + } +} diff --git a/src/main/github/project-view/view-configuration-query.ts b/src/main/github/project-view/view-configuration-query.ts new file mode 100644 index 00000000000..7c221a3ab78 --- /dev/null +++ b/src/main/github/project-view/view-configuration-query.ts @@ -0,0 +1,258 @@ +import type { + GitHubProjectField, + GitHubProjectOwnerType, + GitHubProjectSort, + GitHubProjectView, + GitHubProjectViewLayout +} from '../../../shared/github/project-types' +import type { GitHubProjectViewError } from '../../../shared/github/project-result-types' +import { driftError } from './project-error-classification' +import { projectGhExecOptions, runGraphql, type GraphqlVars } from './internals' +import { normalizeField, type RawProjectV2Field } from './response-normalization' + +const VIEWS_PAGE_SIZE = 20 +const FIELDS_PAGE_SIZE = 50 + +export const FIELD_CONFIG_FRAGMENT = ` +fragment FieldConfig on ProjectV2FieldConfiguration { + __typename + ... on ProjectV2Field { id name dataType } + ... on ProjectV2SingleSelectField { + id + name + dataType + options { id name color } + } + ... on ProjectV2IterationField { + id + name + dataType + configuration { + iterations { id title startDate duration } + completedIterations { id title startDate duration } + } + } +} +` + +type RawProjectConfig = { + id?: string + title?: string + url?: string + views?: { + pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } + nodes?: (RawProjectView | null)[] + } +} + +export type RawProjectView = { + id?: string + number?: number + name?: string + layout?: string + filter?: string | null + fields?: { + pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } + nodes?: (RawProjectV2Field | null)[] + } + groupByFields?: { nodes?: (RawProjectV2Field | null)[] } + sortByFields?: { + nodes?: ({ direction?: string; field?: RawProjectV2Field | null } | null)[] + } +} + +export function ownerQueryRoot(ownerType: GitHubProjectOwnerType): 'organization' | 'user' { + return ownerType === 'organization' ? 'organization' : 'user' +} + +export async function fetchProjectViewsPage(args: { + owner: string + ownerType: GitHubProjectOwnerType + projectNumber: number + host?: string + after: string | null +}): Promise< + | { + ok: true + project: { id: string; title: string; url: string } + views: RawProjectView[] + hasNextPage: boolean + endCursor: string | null + } + | { ok: false; error: GitHubProjectViewError } +> { + const root = ownerQueryRoot(args.ownerType) + const afterArg = args.after ? `, after: $after` : '' + const afterVar = args.after ? `$after:String!, ` : '' + const query = ` + query(${afterVar}$owner:String!, $num:Int!) { + ${root}(login:$owner) { + projectV2(number:$num) { + id title url + views(first:${VIEWS_PAGE_SIZE}${afterArg}) { + pageInfo { hasNextPage endCursor } + nodes { + id number name layout filter + fields(first:${FIELDS_PAGE_SIZE}) { + pageInfo { hasNextPage endCursor } + nodes { ...FieldConfig } + } + groupByFields(first:10) { nodes { ...FieldConfig } } + sortByFields(first:10) { + nodes { direction field { ...FieldConfig } } + } + } + } + } + } + } + ${FIELD_CONFIG_FRAGMENT} + ` + const vars: GraphqlVars = { owner: args.owner, num: args.projectNumber } + if (args.after) { + vars.after = args.after + } + const result = await runGraphql>( + query, + vars, + projectGhExecOptions(args.host) + ) + if (!result.ok) { + return result + } + const project = result.data[root]?.projectV2 ?? null + if (!project || typeof project.id !== 'string') { + return { ok: false, error: { type: 'not_found', message: 'Project not found.' } } + } + const pageInfo = project.views?.pageInfo + return { + ok: true, + project: { id: project.id, title: project.title ?? '', url: project.url ?? '' }, + views: (project.views?.nodes ?? []).filter((view): view is RawProjectView => view !== null), + hasNextPage: pageInfo?.hasNextPage === true, + endCursor: pageInfo?.endCursor ?? null + } +} + +export async function fetchViewFieldsContinuation( + viewId: string, + after: string, + host?: string +): Promise< + { ok: true; fields: RawProjectV2Field[] } | { ok: false; error: GitHubProjectViewError } +> { + // Address the view directly instead of re-walking all views for every field page. + const query = ` + query($after:String!, $viewId:ID!) { + node(id:$viewId) { + ... on ProjectV2View { + id + fields(first:${FIELDS_PAGE_SIZE}, after:$after) { + pageInfo { hasNextPage endCursor } + nodes { ...FieldConfig } + } + } + } + } + ${FIELD_CONFIG_FRAGMENT} + ` + const collected: RawProjectV2Field[] = [] + let cursor: string | null = after + while (cursor !== null) { + const result = await runGraphql<{ + node?: { + id?: string + fields?: { + pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } + nodes?: (RawProjectV2Field | null)[] + } + } | null + }>(query, { viewId, after: cursor }, projectGhExecOptions(host)) + if (!result.ok) { + return result + } + const view = result.data.node ?? null + if (!view) { + return { ok: false, error: driftError('view disappeared during field pagination') } + } + collected.push( + ...(view.fields?.nodes ?? []).filter((field): field is RawProjectV2Field => field !== null) + ) + const pageInfo = view.fields?.pageInfo + cursor = + pageInfo?.hasNextPage === true && typeof pageInfo.endCursor === 'string' + ? pageInfo.endCursor + : null + } + return { ok: true, fields: collected } +} + +export function finalizeView( + raw: RawProjectView, + extraFields: RawProjectV2Field[] +): { ok: true; view: GitHubProjectView } | { ok: false; drift: GitHubProjectViewError } { + if (typeof raw.id !== 'string' || typeof raw.layout !== 'string') { + return { ok: false, drift: driftError('view missing id or layout') } + } + const fields: GitHubProjectField[] = [] + for (const field of [...(raw.fields?.nodes ?? []), ...extraFields]) { + const normalized = normalizeField(field) + if (normalized) { + fields.push(normalized) + } + } + const groupByFields: GitHubProjectField[] = [] + for (const field of raw.groupByFields?.nodes ?? []) { + const normalized = normalizeField(field) + if (normalized) { + groupByFields.push(normalized) + } + } + const sortByFields: GitHubProjectSort[] = [] + for (const sort of raw.sortByFields?.nodes ?? []) { + if (!sort || (sort.direction !== 'ASC' && sort.direction !== 'DESC')) { + continue + } + const field = normalizeField(sort.field) + if (field) { + sortByFields.push({ direction: sort.direction, field }) + } + } + return { + ok: true, + view: { + id: raw.id, + number: typeof raw.number === 'number' ? raw.number : 0, + name: typeof raw.name === 'string' ? raw.name : '', + layout: raw.layout as GitHubProjectViewLayout, + filter: typeof raw.filter === 'string' ? raw.filter : '', + fields, + groupByFields, + sortByFields + } + } +} + +export function matchesSelector( + raw: RawProjectView, + selector: { viewId?: string; viewNumber?: number; viewName?: string } +): 'none' | 'id' | 'number' | 'name' | 'default' { + if (selector.viewId && raw.id === selector.viewId) { + return 'id' + } + if (selector.viewNumber !== undefined && raw.number === selector.viewNumber) { + return 'number' + } + if (selector.viewName && raw.name === selector.viewName) { + return 'name' + } + if ( + selector.viewId === undefined && + selector.viewNumber === undefined && + selector.viewName === undefined && + raw.layout === 'TABLE_LAYOUT' + ) { + return 'default' + } + return 'none' +} diff --git a/src/main/github/project-view/work-item-details-query.ts b/src/main/github/project-view/work-item-details-query.ts new file mode 100644 index 00000000000..ab033fe64b5 --- /dev/null +++ b/src/main/github/project-view/work-item-details-query.ts @@ -0,0 +1,187 @@ +import { assertPositiveInt, projectGhExecOptions, runGraphql, validateSlugArgs } from './internals' +import type { PRComment } from '../../../shared/github/comment-types' +import type { GitHubAssignableUser } from '../../../shared/github/pull-request-types' +import type { GitHubWorkItemDetails } from '../../../shared/github/work-item-types' +import type { ProjectWorkItemDetailsBySlugResult } from '../../../shared/github/project-result-types' +import type { ProjectWorkItemDetailsBySlugArgs } from '../../../shared/github/project-request-types' + +type RawUser = { login?: string; name?: string | null; avatarUrl?: string | null } +type RawLabel = { name?: string; color?: string } +type RawWorkItemContent = { + id?: string + number?: number + title?: string + url?: string + state?: string + stateReason?: string | null + isDraft?: boolean + labels?: { nodes?: RawLabel[] } + assignees?: { nodes?: RawUser[] } +} + +type RawWorkItemDetails = RawWorkItemContent & { + updatedAt?: string + body?: string + headRefName?: string + baseRefName?: string + author?: { login?: string } | null + participants?: { nodes?: RawUser[] } + comments?: { + nodes?: ({ + databaseId?: number + author?: { login?: string; avatarUrl?: string; __typename?: string } | null + body?: string + createdAt?: string + url?: string + } | null)[] + } +} + +export async function getWorkItemDetailsBySlug( + args: ProjectWorkItemDetailsBySlugArgs +): Promise { + const v = validateSlugArgs(args.owner, args.repo) + if (!v.ok) { + return v + } + const n = assertPositiveInt(args.number, 'number') + if (!n.ok) { + return { ok: false, error: n.error } + } + if (args.type !== 'issue' && args.type !== 'pr') { + return { ok: false, error: { type: 'validation_error', message: 'Invalid type.' } } + } + + // Single GraphQL round-trip to fetch the issue/PR summary + comments + labels + assignees. + const contentFrag = + args.type === 'issue' + ? ` + issue(number:$num) { + id number title url state stateReason updatedAt + body + author { login } + labels(first:50) { nodes { name } } + assignees(first:50) { nodes { login } } + participants(first:50) { nodes { login name avatarUrl } } + comments(first:100) { + nodes { + databaseId + author { login avatarUrl __typename } + body createdAt url + } + } + } + ` + : ` + pullRequest(number:$num) { + id number title url state isDraft updatedAt headRefName baseRefName + body + author { login } + labels(first:50) { nodes { name } } + assignees(first:50) { nodes { login } } + participants(first:50) { nodes { login name avatarUrl } } + comments(first:100) { + nodes { + databaseId + author { login avatarUrl __typename } + body createdAt url + } + } + } + ` + const query = ` + query($owner:String!, $repo:String!, $num:Int!) { + repository(owner:$owner, name:$repo) { + ${contentFrag} + } + } + ` + const res = await runGraphql<{ + repository?: { + issue?: RawWorkItemDetails | null + pullRequest?: RawWorkItemDetails | null + } | null + }>( + query, + { owner: args.owner, repo: args.repo, num: args.number }, + projectGhExecOptions(args.host) + ) + if (!res.ok) { + return { ok: false, error: res.error } + } + const raw = args.type === 'issue' ? res.data.repository?.issue : res.data.repository?.pullRequest + if (!raw) { + return { ok: false, error: { type: 'not_found', message: 'Item not found.' } } + } + + const labels = (raw.labels?.nodes ?? []) + .map((l) => l?.name) + .filter((name): name is string => typeof name === 'string') + const assignees = (raw.assignees?.nodes ?? []) + .map((assignee) => assignee?.login) + .filter((login): login is string => typeof login === 'string') + const comments: PRComment[] = [] + for (const comment of raw.comments?.nodes ?? []) { + if (!comment || typeof comment.body !== 'string') { + continue + } + comments.push({ + id: typeof comment.databaseId === 'number' ? comment.databaseId : Date.now(), + author: comment.author?.login ?? '', + authorAvatarUrl: comment.author?.avatarUrl ?? '', + body: comment.body, + createdAt: typeof comment.createdAt === 'string' ? comment.createdAt : '', + url: typeof comment.url === 'string' ? comment.url : '', + isBot: comment.author?.__typename === 'Bot' + }) + } + const participants: GitHubAssignableUser[] = [] + for (const participant of raw.participants?.nodes ?? []) { + if (participant && typeof participant.login === 'string') { + participants.push({ + login: participant.login, + name: participant.name ?? null, + avatarUrl: participant.avatarUrl ?? '' + }) + } + } + + const state: 'open' | 'closed' | 'merged' | 'draft' = + args.type === 'pr' + ? raw.isDraft + ? 'draft' + : raw.state === 'MERGED' + ? 'merged' + : raw.state === 'CLOSED' + ? 'closed' + : 'open' + : raw.state === 'CLOSED' + ? 'closed' + : 'open' + + const details: GitHubWorkItemDetails = { + item: { + id: typeof raw.id === 'string' ? raw.id : '', + type: args.type, + number: typeof raw.number === 'number' ? raw.number : args.number, + title: typeof raw.title === 'string' ? raw.title : '', + state, + url: typeof raw.url === 'string' ? raw.url : '', + labels, + updatedAt: typeof raw.updatedAt === 'string' ? raw.updatedAt : '', + author: typeof raw.author?.login === 'string' ? raw.author.login : null, + branchName: + args.type === 'pr' && typeof raw.headRefName === 'string' ? raw.headRefName : undefined, + baseRefName: + args.type === 'pr' && typeof raw.baseRefName === 'string' ? raw.baseRefName : undefined + }, + body: typeof raw.body === 'string' ? raw.body : '', + comments, + participants, + // Why: PR files/checks/review-thread tabs depend on a local repo path and + // are out of Project-mode slug scope for v1. Omit them here; the dialog + // branches on their absence and hides those tabs. + assignees + } + return { ok: true, details } +} diff --git a/src/main/github/rate-limit.test.ts b/src/main/github/rate-limit.test.ts index 323f729ea26..f236113cd4f 100644 --- a/src/main/github/rate-limit.test.ts +++ b/src/main/github/rate-limit.test.ts @@ -4,9 +4,7 @@ const { ghExecFileAsyncMock } = vi.hoisted(() => ({ ghExecFileAsyncMock: vi.fn() })) -vi.mock('../git/runner', () => ({ - ghExecFileAsync: ghExecFileAsyncMock -})) +vi.mock('../git/github-cli-runner', () => ({ ghExecFileAsync: ghExecFileAsyncMock })) vi.mock('./gh-utils', () => ({ acquire: vi.fn(), diff --git a/src/main/github/rate-limit.ts b/src/main/github/rate-limit.ts index f75d2810321..d831baa771a 100644 --- a/src/main/github/rate-limit.ts +++ b/src/main/github/rate-limit.ts @@ -12,7 +12,7 @@ import type { import { isDefaultGitHubHost } from '../../shared/github/repository-identity-key' import { isWslUncPath } from '../../shared/wsl-paths' import { acquire, release } from './gh-utils' -import { ghExecFileAsync } from '../git/runner' +import { ghExecFileAsync } from '../git/github-cli-runner' import { clearGhRateLimitBlock, getGhRateLimitBlockedUntilMs, diff --git a/src/main/github/review-head-remote.test.ts b/src/main/github/review-head-remote.test.ts index 216907a7790..c3ee8867d68 100644 --- a/src/main/github/review-head-remote.test.ts +++ b/src/main/github/review-head-remote.test.ts @@ -5,7 +5,7 @@ const { getDefaultRemoteMock, getGitHubApiRepositoryForRemoteMock } = vi.hoisted getGitHubApiRepositoryForRemoteMock: vi.fn() })) -vi.mock('../git/repo', () => ({ getDefaultRemote: getDefaultRemoteMock })) +vi.mock('../git/repository-remotes', () => ({ getDefaultRemote: getDefaultRemoteMock })) vi.mock('./github-api-repository', () => ({ getGitHubApiRepositoryForRemote: getGitHubApiRepositoryForRemoteMock })) diff --git a/src/main/github/review-head-remote.ts b/src/main/github/review-head-remote.ts index e408d626ad2..3c83074ed07 100644 --- a/src/main/github/review-head-remote.ts +++ b/src/main/github/review-head-remote.ts @@ -1,6 +1,6 @@ import type { IssueSourcePreference } from '../../shared/repo-types' import { pickPreferredGitRemote } from '../../shared/preferred-git-remote' -import { getDefaultRemote } from '../git/repo' +import { getDefaultRemote } from '../git/repository-remotes' import { getGitHubApiRepositoryForRemote } from './github-api-repository' type GitExec = (args: string[]) => Promise<{ stdout: string; stderr: string }> diff --git a/src/main/github/stacked-pr-creation.test.ts b/src/main/github/stacked-pr-creation.test.ts index e97ea14f2a4..262d6c6496f 100644 --- a/src/main/github/stacked-pr-creation.test.ts +++ b/src/main/github/stacked-pr-creation.test.ts @@ -1,3 +1,4 @@ +vi.mock('../git/github-cli-runner', () => ({ ghExecFileAsync: ghExecFileAsyncMock })) import { beforeEach, describe, expect, it, vi } from 'vitest' const { ghExecFileAsyncMock, repositoryMock } = vi.hoisted(() => ({ diff --git a/src/main/github/stacked-pr-creation.ts b/src/main/github/stacked-pr-creation.ts index 2d060f65a83..7b1be16d218 100644 --- a/src/main/github/stacked-pr-creation.ts +++ b/src/main/github/stacked-pr-creation.ts @@ -1,3 +1,4 @@ +import { ghExecFileAsync } from '../git/github-cli-runner' import type { CreateStackedHostedReviewInput, CreateStackedHostedReviewResult @@ -7,7 +8,7 @@ import { normalizeHostedReviewBaseRef, normalizeHostedReviewHeadRef } from '../../shared/hosted-review-refs' -import { acquire, ghExecFileAsync, ghRepoExecOptions, githubRepoContext, release } from './gh-utils' +import { acquire, ghRepoExecOptions, githubRepoContext, release } from './gh-utils' import { getOriginGitHubApiRepository, githubHostExecOptions, diff --git a/src/main/github/work-item-details-concurrency.test.ts b/src/main/github/work-item-details-concurrency.test.ts index 3fdbacb6491..58564bcf33b 100644 --- a/src/main/github/work-item-details-concurrency.test.ts +++ b/src/main/github/work-item-details-concurrency.test.ts @@ -1,3 +1,4 @@ +vi.mock('../git/github-cli-runner', () => ({ ghExecFileAsync: ghExecFileAsyncMock })) import { beforeEach, describe, expect, it, vi } from 'vitest' const { diff --git a/src/main/github/work-item-details-enterprise-host.test.ts b/src/main/github/work-item-details-enterprise-host.test.ts index be2576051b5..b6bceffa80e 100644 --- a/src/main/github/work-item-details-enterprise-host.test.ts +++ b/src/main/github/work-item-details-enterprise-host.test.ts @@ -1,3 +1,4 @@ +vi.mock('../git/github-cli-runner', () => ({ ghExecFileAsync: ghExecFileAsyncMock })) import { beforeEach, describe, expect, it, vi } from 'vitest' type RateLimitGuardResult = diff --git a/src/main/github/work-item-details-file-viewed.test.ts b/src/main/github/work-item-details-file-viewed.test.ts index 6075803493c..14b44c3bb16 100644 --- a/src/main/github/work-item-details-file-viewed.test.ts +++ b/src/main/github/work-item-details-file-viewed.test.ts @@ -1,3 +1,4 @@ +vi.mock('../git/github-cli-runner', () => ({ ghExecFileAsync: ghExecFileAsyncMock })) import { beforeEach, describe, expect, it, vi } from 'vitest' type RateLimitGuardResult = diff --git a/src/main/github/work-item-details-pr-files.test.ts b/src/main/github/work-item-details-pr-files.test.ts index cac3c33eaf4..0c61dbc6045 100644 --- a/src/main/github/work-item-details-pr-files.test.ts +++ b/src/main/github/work-item-details-pr-files.test.ts @@ -1,3 +1,4 @@ +vi.mock('../git/github-cli-runner', () => ({ ghExecFileAsync: ghExecFileAsyncMock })) import { beforeEach, describe, expect, it, vi } from 'vitest' type RateLimitGuardResult = diff --git a/src/main/github/work-item-details.test.ts b/src/main/github/work-item-details.test.ts index 11a063b51ba..f7ad22d1ebb 100644 --- a/src/main/github/work-item-details.test.ts +++ b/src/main/github/work-item-details.test.ts @@ -1,3 +1,4 @@ +vi.mock('../git/github-cli-runner', () => ({ ghExecFileAsync: ghExecFileAsyncMock })) import { beforeEach, describe, expect, it, vi } from 'vitest' type RateLimitGuardResult = diff --git a/src/main/github/work-item-details.ts b/src/main/github/work-item-details.ts index 276ee9d33eb..17dba7753b4 100644 --- a/src/main/github/work-item-details.ts +++ b/src/main/github/work-item-details.ts @@ -1,3 +1,4 @@ +import { ghExecFileAsync } from '../git/github-cli-runner' /* eslint-disable max-lines -- Why: groups the PR/Issue fetch paths and file-contents resolver so caching/rate-limit strategy lives in one place. */ import type { PRCheckDetail } from '../../shared/github/check-types' import type { @@ -14,7 +15,6 @@ import type { import type { GitHubWorkItem, GitHubWorkItemDetails } from '../../shared/github/work-item-types' import type { IssueSourcePreference } from '../../shared/repo-types' import { - ghExecFileAsync, acquire, release, ghRepoExecOptions, diff --git a/src/main/gitlab/client-mr-auth-rate-limit.test.ts b/src/main/gitlab/client-mr-auth-rate-limit.test.ts index abf928a7c9f..b9740f137b8 100644 --- a/src/main/gitlab/client-mr-auth-rate-limit.test.ts +++ b/src/main/gitlab/client-mr-auth-rate-limit.test.ts @@ -1,3 +1,4 @@ +vi.mock('../git/gitlab-cli-runner', () => ({ glabExecFileAsync: glabExecFileAsyncMock })) import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as GlUtils from './gl-utils' diff --git a/src/main/gitlab/client-mr-branch-lookup.test.ts b/src/main/gitlab/client-mr-branch-lookup.test.ts index 948e3510b03..8f9423578a9 100644 --- a/src/main/gitlab/client-mr-branch-lookup.test.ts +++ b/src/main/gitlab/client-mr-branch-lookup.test.ts @@ -1,3 +1,4 @@ +vi.mock('../git/gitlab-cli-runner', () => ({ glabExecFileAsync: glabExecFileAsyncMock })) import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as GlUtils from './gl-utils' diff --git a/src/main/gitlab/client-mr-job-ci.test.ts b/src/main/gitlab/client-mr-job-ci.test.ts index 69145a58d15..e00fe2f25b8 100644 --- a/src/main/gitlab/client-mr-job-ci.test.ts +++ b/src/main/gitlab/client-mr-job-ci.test.ts @@ -1,3 +1,4 @@ +vi.mock('../git/gitlab-cli-runner', () => ({ glabExecFileAsync: glabExecFileAsyncMock })) import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as GlUtils from './gl-utils' diff --git a/src/main/gitlab/client-mr-listing.test.ts b/src/main/gitlab/client-mr-listing.test.ts index 63eaa0ca512..bfe6b963427 100644 --- a/src/main/gitlab/client-mr-listing.test.ts +++ b/src/main/gitlab/client-mr-listing.test.ts @@ -1,3 +1,4 @@ +vi.mock('../git/gitlab-cli-runner', () => ({ glabExecFileAsync: glabExecFileAsyncMock })) import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as GlUtils from './gl-utils' diff --git a/src/main/gitlab/client-mr-review-actions.test.ts b/src/main/gitlab/client-mr-review-actions.test.ts index 13410290e4b..d8d48bb3158 100644 --- a/src/main/gitlab/client-mr-review-actions.test.ts +++ b/src/main/gitlab/client-mr-review-actions.test.ts @@ -1,3 +1,4 @@ +vi.mock('../git/gitlab-cli-runner', () => ({ glabExecFileAsync: glabExecFileAsyncMock })) import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as GlUtils from './gl-utils' diff --git a/src/main/gitlab/client-work-items.test.ts b/src/main/gitlab/client-work-items.test.ts index 80e412862d8..f12563ca6f4 100644 --- a/src/main/gitlab/client-work-items.test.ts +++ b/src/main/gitlab/client-work-items.test.ts @@ -1,3 +1,4 @@ +vi.mock('../git/gitlab-cli-runner', () => ({ glabExecFileAsync: glabExecFileAsyncMock })) import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as GlUtils from './gl-utils' diff --git a/src/main/gitlab/client.test.ts b/src/main/gitlab/client.test.ts index 038083def0e..4f74393c376 100644 --- a/src/main/gitlab/client.test.ts +++ b/src/main/gitlab/client.test.ts @@ -1,3 +1,4 @@ +vi.mock('../git/gitlab-cli-runner', () => ({ glabExecFileAsync: glabExecFileAsyncMock })) import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as GlUtils from './gl-utils' diff --git a/src/main/gitlab/client.ts b/src/main/gitlab/client.ts index 50c541c0213..12daf1cf0ad 100644 --- a/src/main/gitlab/client.ts +++ b/src/main/gitlab/client.ts @@ -1,3 +1,4 @@ +import { glabExecFileAsync } from '../git/gitlab-cli-runner' /* eslint-disable max-lines -- co-locates GitLab MR/issue/work-item operations sharing one acquire/release pattern. */ import type { ClassifiedError } from '../../shared/classified-error' import type { @@ -34,7 +35,6 @@ import { glabHostnameArgs, glabRepoExecOptions, glabApiWithHeaders, - glabExecFileAsync, parseGlabAuthStatusHosts, parseGlabJsonList, release, diff --git a/src/main/gitlab/gitlab-known-host-probe.ts b/src/main/gitlab/gitlab-known-host-probe.ts index 577baf9ad67..2d951624d97 100644 --- a/src/main/gitlab/gitlab-known-host-probe.ts +++ b/src/main/gitlab/gitlab-known-host-probe.ts @@ -1,6 +1,6 @@ import { runCoalescedProbe, type CoalescedProbes } from '../git/coalesced-probe' import { NEGATIVE_ENTRY_TTL_MS } from '../git/remote-ref-probe-cache' -import { glabExecFileAsync } from '../git/runner' +import { glabExecFileAsync } from '../git/gitlab-cli-runner' import { getSshGitProviderGeneration } from '../providers/ssh-git-dispatch' import { DEFAULT_GITLAB_HOSTS, normalizeGitLabHost } from './project-ref-parser' diff --git a/src/main/gitlab/gitlab-project-ref-resolution.ts b/src/main/gitlab/gitlab-project-ref-resolution.ts index 96186f1ccf2..4f3d78710bb 100644 --- a/src/main/gitlab/gitlab-project-ref-resolution.ts +++ b/src/main/gitlab/gitlab-project-ref-resolution.ts @@ -1,4 +1,4 @@ -import { glabExecFileAsync } from '../git/runner' +import { glabExecFileAsync } from '../git/gitlab-cli-runner' import { isTransientGitProbeError, readRemoteUrl } from '../git/remote-url-probe' import { NEGATIVE_ENTRY_TTL_MS } from '../git/remote-ref-probe-cache' import { getSshGitProviderGeneration } from '../providers/ssh-git-dispatch' diff --git a/src/main/gitlab/gl-utils.test.ts b/src/main/gitlab/gl-utils.test.ts index 4ffd0d11ed5..9af3962675c 100644 --- a/src/main/gitlab/gl-utils.test.ts +++ b/src/main/gitlab/gl-utils.test.ts @@ -10,6 +10,7 @@ vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock, glabExecFileAsync: glabExecFileAsyncMock })) +vi.mock('../git/gitlab-cli-runner', () => ({ glabExecFileAsync: glabExecFileAsyncMock })) import { _getProjectRefCacheSize, diff --git a/src/main/gitlab/gl-utils.ts b/src/main/gitlab/gl-utils.ts index 0d361356692..4e156bdd581 100644 --- a/src/main/gitlab/gl-utils.ts +++ b/src/main/gitlab/gl-utils.ts @@ -1,7 +1,8 @@ -import { gitExecFileAsync, glabExecFileAsync } from '../git/runner' +import { gitExecFileAsync } from '../git/runner' +import { glabExecFileAsync } from '../git/gitlab-cli-runner' import { parseGlabApiResponse, type GlabApiResponse } from './glab-api-response' -export { glabExecFileAsync, gitExecFileAsync } +export { gitExecFileAsync } export { classifyGlabError, classifyJobLogError, diff --git a/src/main/gitlab/issue-update.ts b/src/main/gitlab/issue-update.ts index 369e889ff09..0677713edc0 100644 --- a/src/main/gitlab/issue-update.ts +++ b/src/main/gitlab/issue-update.ts @@ -1,10 +1,10 @@ +import { glabExecFileAsync } from '../git/gitlab-cli-runner' import type { GitLabIssueUpdate } from '../../shared/gitlab-types' import type { IssueSourcePreference } from '../../shared/repo-types' import { acquire, classifyGlabError, getGlabKnownHosts, - glabExecFileAsync, glabHostnameArgs, glabRepoExecOptions, release, diff --git a/src/main/gitlab/issues.test.ts b/src/main/gitlab/issues.test.ts index c86ee077fdf..28df3f86267 100644 --- a/src/main/gitlab/issues.test.ts +++ b/src/main/gitlab/issues.test.ts @@ -1,3 +1,4 @@ +vi.mock('../git/gitlab-cli-runner', () => ({ glabExecFileAsync: glabExecFileAsyncMock })) import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as GlUtils from './gl-utils' diff --git a/src/main/gitlab/issues.ts b/src/main/gitlab/issues.ts index bfe19ef411b..ecbe9e1c455 100644 --- a/src/main/gitlab/issues.ts +++ b/src/main/gitlab/issues.ts @@ -1,9 +1,10 @@ +import { glabExecFileAsync } from '../git/gitlab-cli-runner' import type { ClassifiedError } from '../../shared/classified-error' import type { GitLabCommentResult, GitLabIssueInfo, MRComment } from '../../shared/gitlab-types' import type { IssueSourcePreference } from '../../shared/repo-types' import { mapGitLabIssueInfo } from './mappers' // prettier-ignore -import { glabExecFileAsync, acquire, release, getIssueProjectRef, resolveIssueSource, classifyGlabError, classifyListFetchError, getGlabKnownHosts, glabRepoExecOptions, glabHostnameArgs, parseGlabJsonList, type LocalGitExecOptions, type ProjectRef } from './gl-utils' +import { acquire, release, getIssueProjectRef, resolveIssueSource, classifyGlabError, classifyListFetchError, getGlabKnownHosts, glabRepoExecOptions, glabHostnameArgs, parseGlabJsonList, type LocalGitExecOptions, type ProjectRef } from './gl-utils' import { encodedProject } from './project-path-encoding' // Why: parallel to GitHub's IssueListResult — distinguishes a successful- diff --git a/src/main/gitlab/merge-request-creation-lookup.ts b/src/main/gitlab/merge-request-creation-lookup.ts index e5c8303ce1a..246e3f368cf 100644 --- a/src/main/gitlab/merge-request-creation-lookup.ts +++ b/src/main/gitlab/merge-request-creation-lookup.ts @@ -1,13 +1,9 @@ +import { glabExecFileAsync } from '../git/gitlab-cli-runner' import { getHostedReviewLocalGitOptions, type HostedReviewExecutionOptions } from '../source-control/hosted-review-git-options' -import { - glabExecFileAsync, - glabHostnameArgs, - glabRepoExecOptions, - type ProjectRef -} from './gl-utils' +import { glabHostnameArgs, glabRepoExecOptions, type ProjectRef } from './gl-utils' export function parseMergeRequestPayload(stdout: string): { number: number; url: string } | null { const trimmed = stdout.trim() diff --git a/src/main/gitlab/merge-request-creation.test.ts b/src/main/gitlab/merge-request-creation.test.ts index b7ead7c98be..418b606656b 100644 --- a/src/main/gitlab/merge-request-creation.test.ts +++ b/src/main/gitlab/merge-request-creation.test.ts @@ -1,3 +1,4 @@ +vi.mock('../git/gitlab-cli-runner', () => ({ glabExecFileAsync: glabExecFileAsyncMock })) import { beforeEach, describe, expect, it, vi } from 'vitest' const { diff --git a/src/main/gitlab/merge-request-creation.ts b/src/main/gitlab/merge-request-creation.ts index 1b7d253f037..68dcc125909 100644 --- a/src/main/gitlab/merge-request-creation.ts +++ b/src/main/gitlab/merge-request-creation.ts @@ -1,3 +1,4 @@ +import { glabExecFileAsync } from '../git/gitlab-cli-runner' import { readFile } from 'node:fs/promises' import { join } from 'node:path' import type { CreateHostedReviewInput, CreateHostedReviewResult } from '../../shared/hosted-review' @@ -13,13 +14,7 @@ import { type HostedReviewExecutionOptions } from '../source-control/hosted-review-git-options' import { getProjectSlug } from './client' -import { - acquire, - glabExecFileAsync, - glabHostnameArgs, - glabRepoExecOptions, - release -} from './gl-utils' +import { acquire, glabHostnameArgs, glabRepoExecOptions, release } from './gl-utils' import { findOpenMRByHeadBase, parseMergeRequestPayload } from './merge-request-creation-lookup' function execErrorMessage(error: unknown): string { diff --git a/src/main/gitlab/mr-discussion-notes.ts b/src/main/gitlab/mr-discussion-notes.ts index fa3aac2dc6a..b6a849a7022 100644 --- a/src/main/gitlab/mr-discussion-notes.ts +++ b/src/main/gitlab/mr-discussion-notes.ts @@ -1,9 +1,9 @@ +import { glabExecFileAsync } from '../git/gitlab-cli-runner' import type { MRComment } from '../../shared/gitlab-types' import { encodedProject } from './project-path-encoding' import { glabHostnameArgs, glabRepoExecOptions, - glabExecFileAsync, type LocalGitExecOptions, type ProjectRef } from './gl-utils' diff --git a/src/main/gitlab/mr-file-diffs.ts b/src/main/gitlab/mr-file-diffs.ts index 0e16567a294..555033e9c6c 100644 --- a/src/main/gitlab/mr-file-diffs.ts +++ b/src/main/gitlab/mr-file-diffs.ts @@ -1,9 +1,9 @@ +import { glabExecFileAsync } from '../git/gitlab-cli-runner' import type { GitLabMRFile } from '../../shared/gitlab-types' import { encodedProject } from './project-path-encoding' import { glabHostnameArgs, glabRepoExecOptions, - glabExecFileAsync, type LocalGitExecOptions, type ProjectRef } from './gl-utils' diff --git a/src/main/gitlab/mr-reviewers-and-approvals.ts b/src/main/gitlab/mr-reviewers-and-approvals.ts index cdbe9870e37..139a9d7a20d 100644 --- a/src/main/gitlab/mr-reviewers-and-approvals.ts +++ b/src/main/gitlab/mr-reviewers-and-approvals.ts @@ -1,10 +1,10 @@ +import { glabExecFileAsync } from '../git/gitlab-cli-runner' import type { GitLabAssignableUser, GitLabMRApprovalState } from '../../shared/gitlab-types' import { mapGitLabUser, type GitLabRawUser } from './gitlab-assignable-user-mapping' import { encodedProject } from './project-path-encoding' import { glabHostnameArgs, glabRepoExecOptions, - glabExecFileAsync, type LocalGitExecOptions, type ProjectRef } from './gl-utils' diff --git a/src/main/gitlab/pipeline-job-graph.ts b/src/main/gitlab/pipeline-job-graph.ts index 46de918f49a..f030fe733ac 100644 --- a/src/main/gitlab/pipeline-job-graph.ts +++ b/src/main/gitlab/pipeline-job-graph.ts @@ -1,9 +1,9 @@ +import { glabExecFileAsync } from '../git/gitlab-cli-runner' import type { GitLabPipelineJob } from '../../shared/gitlab-types' import { encodedProject } from './project-path-encoding' import { glabHostnameArgs, glabRepoExecOptions, - glabExecFileAsync, type LocalGitExecOptions, type ProjectRef } from './gl-utils' diff --git a/src/main/gitlab/project-label-and-member-lookup.ts b/src/main/gitlab/project-label-and-member-lookup.ts index fcad19b9fdf..7cbc1bce69d 100644 --- a/src/main/gitlab/project-label-and-member-lookup.ts +++ b/src/main/gitlab/project-label-and-member-lookup.ts @@ -1,9 +1,9 @@ +import { glabExecFileAsync } from '../git/gitlab-cli-runner' import type { GitLabAssignableUser } from '../../shared/gitlab-types' import type { IssueSourcePreference } from '../../shared/repo-types' import { acquire, getGlabKnownHosts, - glabExecFileAsync, glabHostnameArgs, glabRepoExecOptions, release, diff --git a/src/main/gitlab/work-item-details.test.ts b/src/main/gitlab/work-item-details.test.ts index f1e2297e14b..3be4d392253 100644 --- a/src/main/gitlab/work-item-details.test.ts +++ b/src/main/gitlab/work-item-details.test.ts @@ -1,3 +1,4 @@ +vi.mock('../git/gitlab-cli-runner', () => ({ glabExecFileAsync: glabExecFileAsyncMock })) import { beforeEach, describe, expect, it, vi } from 'vitest' const { diff --git a/src/main/gitlab/work-item-details.ts b/src/main/gitlab/work-item-details.ts index 637baa0c78b..e4f1cc681a9 100644 --- a/src/main/gitlab/work-item-details.ts +++ b/src/main/gitlab/work-item-details.ts @@ -1,3 +1,4 @@ +import { glabExecFileAsync } from '../git/gitlab-cli-runner' // Why: aggregated detail-fetch for GitLabItemDialog. Parallel of // src/main/github/work-item-details.ts but scoped to v1 surface — // description body, flattened discussion notes, MR pipeline jobs/reviewers. @@ -20,7 +21,6 @@ import { getGlabKnownHosts, glabHostnameArgs, glabRepoExecOptions, - glabExecFileAsync, release, resolveIssueSource, type LocalGitExecOptions, diff --git a/src/main/hooks-issue-command.test.ts b/src/main/hooks-issue-command.test.ts index e5a1f2f8e3e..827a1b2067a 100644 --- a/src/main/hooks-issue-command.test.ts +++ b/src/main/hooks-issue-command.test.ts @@ -36,6 +36,7 @@ vi.mock('./git/runner', async () => ({ ...(await vi.importActual('./git/runner')), gitExecFileSync: gitExecFileSyncMock })) +vi.mock('./git/git-process-launch', () => ({ gitExecFileSync: gitExecFileSyncMock })) describe('readIssueCommand', () => { it('prefers the local override over the shared orca.yaml command', async () => { diff --git a/src/main/hooks-setup-runner-script.test.ts b/src/main/hooks-setup-runner-script.test.ts index 0fc9ba6037a..33f46102dc0 100644 --- a/src/main/hooks-setup-runner-script.test.ts +++ b/src/main/hooks-setup-runner-script.test.ts @@ -29,6 +29,7 @@ vi.mock('./git/runner', async () => ({ ...(await vi.importActual('./git/runner')), gitExecFileSync: gitExecFileSyncMock })) +vi.mock('./git/git-process-launch', () => ({ gitExecFileSync: gitExecFileSyncMock })) describe('runner script builders', () => { it('builds Windows runners for newline-heavy scripts without line-array splitting', async () => { diff --git a/src/main/hooks.test.ts b/src/main/hooks.test.ts index 12744712543..6dd414b7cbc 100644 --- a/src/main/hooks.test.ts +++ b/src/main/hooks.test.ts @@ -34,6 +34,7 @@ vi.mock('./git/runner', async () => ({ ...(await vi.importActual('./git/runner')), gitExecFileSync: gitExecFileSyncMock })) +vi.mock('./git/git-process-launch', () => ({ gitExecFileSync: gitExecFileSyncMock })) describe('runHook', () => { const makeRepo = (hookSettings?: { diff --git a/src/main/hooks.ts b/src/main/hooks.ts index 7778132e910..a4d7e405102 100644 --- a/src/main/hooks.ts +++ b/src/main/hooks.ts @@ -7,7 +7,7 @@ import { getEffectiveHooksFromConfig } from './effective-hook-config' import { getHookRuntimeTarget, getHookWslContext } from './hook-runtime-target' import { getSetupEnvVars } from './setup-hook-env-vars' import { iterateLfScriptLines } from './setup-runner-script-text' -import { promptGuardShellEnv } from './git/runner' +import { promptGuardShellEnv } from './git/git-environment-policy' import { dropIncoherentCondaActivationEnv } from './pty/conda-activation-env' import { toLinuxPath } from './wsl' import { runWslProcess } from './wsl/wsl-runner' diff --git a/src/main/index.ts b/src/main/index.ts index ae8fed9981d..54e95c48e4d 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -300,7 +300,7 @@ import { moveWorktree } from './git/worktree' import { configureWindowsHostGitEnvironmentReadiness, setDefaultWslDistroOverride -} from './git/runner' +} from './git/command-resolution' import { getRepoIdFromWorktreeId } from '../shared/worktree/id' import { parseWorkspaceKey } from '../shared/workspace-scope' import { setMigrationUnsupportedPtyListener } from './agent-hooks/migration-unsupported-pty-state' diff --git a/src/main/ipc/filesystem-branch-compare-diff.test.ts b/src/main/ipc/filesystem-branch-compare-diff.test.ts index e7aff1b0e5e..bd8cdacf036 100644 --- a/src/main/ipc/filesystem-branch-compare-diff.test.ts +++ b/src/main/ipc/filesystem-branch-compare-diff.test.ts @@ -27,15 +27,47 @@ vi.mock( '../local-downloaded-folder-promotion', async () => (await import('./filesystem-test-harness')).folderPromotionMock ) +vi.mock('../git/status', async () => (await import('./filesystem-test-harness')).gitStatusQueryMock) vi.mock( - '../git/status', - async () => (await import('./filesystem-test-harness')).gitStatusModuleMock + '../git/submodule-status', + async () => (await import('./filesystem-test-harness')).gitSubmoduleStatusMock +) +vi.mock( + '../git/conflict-status', + async () => (await import('./filesystem-test-harness')).gitConflictStatusMock +) +vi.mock( + '../git/status-file-diff', + async () => (await import('./filesystem-test-harness')).gitFileDiffMock +) +vi.mock( + '../git/git-commit-operations', + async () => (await import('./filesystem-test-harness')).gitCommitOperationsMock +) +vi.mock( + '../git/git-index-mutations', + async () => (await import('./filesystem-test-harness')).gitIndexMutationsMock +) +vi.mock( + '../git/git-discard-changes', + async () => (await import('./filesystem-test-harness')).gitDiscardChangesMock +) +vi.mock( + '../git/branch-comparison', + async () => (await import('./filesystem-test-harness')).gitBranchComparisonMock +) +vi.mock( + '../git/commit-comparison', + async () => (await import('./filesystem-test-harness')).gitCommitComparisonMock ) vi.mock( '../git/check-ignored-paths', async () => (await import('./filesystem-test-harness')).gitIgnoredPathsMock ) -vi.mock('../git/worktree', async () => (await import('./filesystem-test-harness')).gitWorktreeMock) +vi.mock( + '../repo-worktrees', + async () => (await import('./filesystem-test-harness')).repoWorktreesMock +) vi.mock( '../providers/ssh-filesystem-dispatch', async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock diff --git a/src/main/ipc/filesystem-commit-message-generation.test.ts b/src/main/ipc/filesystem-commit-message-generation.test.ts index 591ea3565f9..fd5081d6c6f 100644 --- a/src/main/ipc/filesystem-commit-message-generation.test.ts +++ b/src/main/ipc/filesystem-commit-message-generation.test.ts @@ -29,15 +29,47 @@ vi.mock( '../local-downloaded-folder-promotion', async () => (await import('./filesystem-test-harness')).folderPromotionMock ) +vi.mock('../git/status', async () => (await import('./filesystem-test-harness')).gitStatusQueryMock) vi.mock( - '../git/status', - async () => (await import('./filesystem-test-harness')).gitStatusModuleMock + '../git/submodule-status', + async () => (await import('./filesystem-test-harness')).gitSubmoduleStatusMock +) +vi.mock( + '../git/conflict-status', + async () => (await import('./filesystem-test-harness')).gitConflictStatusMock +) +vi.mock( + '../git/status-file-diff', + async () => (await import('./filesystem-test-harness')).gitFileDiffMock +) +vi.mock( + '../git/git-commit-operations', + async () => (await import('./filesystem-test-harness')).gitCommitOperationsMock +) +vi.mock( + '../git/git-index-mutations', + async () => (await import('./filesystem-test-harness')).gitIndexMutationsMock +) +vi.mock( + '../git/git-discard-changes', + async () => (await import('./filesystem-test-harness')).gitDiscardChangesMock +) +vi.mock( + '../git/branch-comparison', + async () => (await import('./filesystem-test-harness')).gitBranchComparisonMock +) +vi.mock( + '../git/commit-comparison', + async () => (await import('./filesystem-test-harness')).gitCommitComparisonMock ) vi.mock( '../git/check-ignored-paths', async () => (await import('./filesystem-test-harness')).gitIgnoredPathsMock ) -vi.mock('../git/worktree', async () => (await import('./filesystem-test-harness')).gitWorktreeMock) +vi.mock( + '../repo-worktrees', + async () => (await import('./filesystem-test-harness')).repoWorktreesMock +) vi.mock( '../providers/ssh-filesystem-dispatch', async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock diff --git a/src/main/ipc/filesystem-commit-message-model-discovery.test.ts b/src/main/ipc/filesystem-commit-message-model-discovery.test.ts index 73154bddab5..40f24d5342f 100644 --- a/src/main/ipc/filesystem-commit-message-model-discovery.test.ts +++ b/src/main/ipc/filesystem-commit-message-model-discovery.test.ts @@ -26,15 +26,47 @@ vi.mock( '../local-downloaded-folder-promotion', async () => (await import('./filesystem-test-harness')).folderPromotionMock ) +vi.mock('../git/status', async () => (await import('./filesystem-test-harness')).gitStatusQueryMock) vi.mock( - '../git/status', - async () => (await import('./filesystem-test-harness')).gitStatusModuleMock + '../git/submodule-status', + async () => (await import('./filesystem-test-harness')).gitSubmoduleStatusMock +) +vi.mock( + '../git/conflict-status', + async () => (await import('./filesystem-test-harness')).gitConflictStatusMock +) +vi.mock( + '../git/status-file-diff', + async () => (await import('./filesystem-test-harness')).gitFileDiffMock +) +vi.mock( + '../git/git-commit-operations', + async () => (await import('./filesystem-test-harness')).gitCommitOperationsMock +) +vi.mock( + '../git/git-index-mutations', + async () => (await import('./filesystem-test-harness')).gitIndexMutationsMock +) +vi.mock( + '../git/git-discard-changes', + async () => (await import('./filesystem-test-harness')).gitDiscardChangesMock +) +vi.mock( + '../git/branch-comparison', + async () => (await import('./filesystem-test-harness')).gitBranchComparisonMock +) +vi.mock( + '../git/commit-comparison', + async () => (await import('./filesystem-test-harness')).gitCommitComparisonMock ) vi.mock( '../git/check-ignored-paths', async () => (await import('./filesystem-test-harness')).gitIgnoredPathsMock ) -vi.mock('../git/worktree', async () => (await import('./filesystem-test-harness')).gitWorktreeMock) +vi.mock( + '../repo-worktrees', + async () => (await import('./filesystem-test-harness')).repoWorktreesMock +) vi.mock( '../providers/ssh-filesystem-dispatch', async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock diff --git a/src/main/ipc/filesystem-download-transfers.test.ts b/src/main/ipc/filesystem-download-transfers.test.ts index 9d031ff4d39..133e0d84c37 100644 --- a/src/main/ipc/filesystem-download-transfers.test.ts +++ b/src/main/ipc/filesystem-download-transfers.test.ts @@ -30,15 +30,47 @@ vi.mock( '../local-downloaded-folder-promotion', async () => (await import('./filesystem-test-harness')).folderPromotionMock ) +vi.mock('../git/status', async () => (await import('./filesystem-test-harness')).gitStatusQueryMock) vi.mock( - '../git/status', - async () => (await import('./filesystem-test-harness')).gitStatusModuleMock + '../git/submodule-status', + async () => (await import('./filesystem-test-harness')).gitSubmoduleStatusMock +) +vi.mock( + '../git/conflict-status', + async () => (await import('./filesystem-test-harness')).gitConflictStatusMock +) +vi.mock( + '../git/status-file-diff', + async () => (await import('./filesystem-test-harness')).gitFileDiffMock +) +vi.mock( + '../git/git-commit-operations', + async () => (await import('./filesystem-test-harness')).gitCommitOperationsMock +) +vi.mock( + '../git/git-index-mutations', + async () => (await import('./filesystem-test-harness')).gitIndexMutationsMock +) +vi.mock( + '../git/git-discard-changes', + async () => (await import('./filesystem-test-harness')).gitDiscardChangesMock +) +vi.mock( + '../git/branch-comparison', + async () => (await import('./filesystem-test-harness')).gitBranchComparisonMock +) +vi.mock( + '../git/commit-comparison', + async () => (await import('./filesystem-test-harness')).gitCommitComparisonMock ) vi.mock( '../git/check-ignored-paths', async () => (await import('./filesystem-test-harness')).gitIgnoredPathsMock ) -vi.mock('../git/worktree', async () => (await import('./filesystem-test-harness')).gitWorktreeMock) +vi.mock( + '../repo-worktrees', + async () => (await import('./filesystem-test-harness')).repoWorktreesMock +) vi.mock( '../providers/ssh-filesystem-dispatch', async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock diff --git a/src/main/ipc/filesystem-git-commit-dispatch.test.ts b/src/main/ipc/filesystem-git-commit-dispatch.test.ts index 0f683936bd7..6d8deb7f558 100644 --- a/src/main/ipc/filesystem-git-commit-dispatch.test.ts +++ b/src/main/ipc/filesystem-git-commit-dispatch.test.ts @@ -23,15 +23,47 @@ vi.mock( '../local-downloaded-folder-promotion', async () => (await import('./filesystem-test-harness')).folderPromotionMock ) +vi.mock('../git/status', async () => (await import('./filesystem-test-harness')).gitStatusQueryMock) vi.mock( - '../git/status', - async () => (await import('./filesystem-test-harness')).gitStatusModuleMock + '../git/submodule-status', + async () => (await import('./filesystem-test-harness')).gitSubmoduleStatusMock +) +vi.mock( + '../git/conflict-status', + async () => (await import('./filesystem-test-harness')).gitConflictStatusMock +) +vi.mock( + '../git/status-file-diff', + async () => (await import('./filesystem-test-harness')).gitFileDiffMock +) +vi.mock( + '../git/git-commit-operations', + async () => (await import('./filesystem-test-harness')).gitCommitOperationsMock +) +vi.mock( + '../git/git-index-mutations', + async () => (await import('./filesystem-test-harness')).gitIndexMutationsMock +) +vi.mock( + '../git/git-discard-changes', + async () => (await import('./filesystem-test-harness')).gitDiscardChangesMock +) +vi.mock( + '../git/branch-comparison', + async () => (await import('./filesystem-test-harness')).gitBranchComparisonMock +) +vi.mock( + '../git/commit-comparison', + async () => (await import('./filesystem-test-harness')).gitCommitComparisonMock ) vi.mock( '../git/check-ignored-paths', async () => (await import('./filesystem-test-harness')).gitIgnoredPathsMock ) -vi.mock('../git/worktree', async () => (await import('./filesystem-test-harness')).gitWorktreeMock) +vi.mock( + '../repo-worktrees', + async () => (await import('./filesystem-test-harness')).repoWorktreesMock +) vi.mock( '../providers/ssh-filesystem-dispatch', async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock diff --git a/src/main/ipc/filesystem-git-status-staging.test.ts b/src/main/ipc/filesystem-git-status-staging.test.ts index 4f3a62ce679..95a9bf01a18 100644 --- a/src/main/ipc/filesystem-git-status-staging.test.ts +++ b/src/main/ipc/filesystem-git-status-staging.test.ts @@ -34,15 +34,47 @@ vi.mock( '../local-downloaded-folder-promotion', async () => (await import('./filesystem-test-harness')).folderPromotionMock ) +vi.mock('../git/status', async () => (await import('./filesystem-test-harness')).gitStatusQueryMock) vi.mock( - '../git/status', - async () => (await import('./filesystem-test-harness')).gitStatusModuleMock + '../git/submodule-status', + async () => (await import('./filesystem-test-harness')).gitSubmoduleStatusMock +) +vi.mock( + '../git/conflict-status', + async () => (await import('./filesystem-test-harness')).gitConflictStatusMock +) +vi.mock( + '../git/status-file-diff', + async () => (await import('./filesystem-test-harness')).gitFileDiffMock +) +vi.mock( + '../git/git-commit-operations', + async () => (await import('./filesystem-test-harness')).gitCommitOperationsMock +) +vi.mock( + '../git/git-index-mutations', + async () => (await import('./filesystem-test-harness')).gitIndexMutationsMock +) +vi.mock( + '../git/git-discard-changes', + async () => (await import('./filesystem-test-harness')).gitDiscardChangesMock +) +vi.mock( + '../git/branch-comparison', + async () => (await import('./filesystem-test-harness')).gitBranchComparisonMock +) +vi.mock( + '../git/commit-comparison', + async () => (await import('./filesystem-test-harness')).gitCommitComparisonMock ) vi.mock( '../git/check-ignored-paths', async () => (await import('./filesystem-test-harness')).gitIgnoredPathsMock ) -vi.mock('../git/worktree', async () => (await import('./filesystem-test-harness')).gitWorktreeMock) +vi.mock( + '../repo-worktrees', + async () => (await import('./filesystem-test-harness')).repoWorktreesMock +) vi.mock( '../providers/ssh-filesystem-dispatch', async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock diff --git a/src/main/ipc/filesystem-list-files-git-directory-expansion.test.ts b/src/main/ipc/filesystem-list-files-git-directory-expansion.test.ts index 5d7507a5615..ccb44667514 100644 --- a/src/main/ipc/filesystem-list-files-git-directory-expansion.test.ts +++ b/src/main/ipc/filesystem-list-files-git-directory-expansion.test.ts @@ -12,6 +12,9 @@ const { gitSpawnMock } = vi.hoisted(() => ({ vi.mock('../git/runner', () => ({ gitSpawnAfterWindowsEnvironmentReady: gitSpawnMock })) +vi.mock('../git/git-process-launch', () => ({ + gitSpawnAfterWindowsEnvironmentReady: gitSpawnMock +})) import { listFilesWithGit } from './filesystem-list-files-git-fallback' import { isFileListingCancellation } from '../../shared/file-listing-cancellation' diff --git a/src/main/ipc/filesystem-list-files-git-fallback.ts b/src/main/ipc/filesystem-list-files-git-fallback.ts index bebab27808d..0091c7cf1ca 100644 --- a/src/main/ipc/filesystem-list-files-git-fallback.ts +++ b/src/main/ipc/filesystem-list-files-git-fallback.ts @@ -1,5 +1,5 @@ import type { ChildProcess } from 'node:child_process' -import { gitSpawnAfterWindowsEnvironmentReady } from '../git/runner' +import { gitSpawnAfterWindowsEnvironmentReady } from '../git/git-process-launch' import { isWslLinkedWorktreeGitRoutingCandidate, prepareWslLinkedWorktreeGitRouting diff --git a/src/main/ipc/filesystem-markdown-document-listing.test.ts b/src/main/ipc/filesystem-markdown-document-listing.test.ts index d70d46c48e1..4e88f2e65b3 100644 --- a/src/main/ipc/filesystem-markdown-document-listing.test.ts +++ b/src/main/ipc/filesystem-markdown-document-listing.test.ts @@ -24,15 +24,47 @@ vi.mock( '../local-downloaded-folder-promotion', async () => (await import('./filesystem-test-harness')).folderPromotionMock ) +vi.mock('../git/status', async () => (await import('./filesystem-test-harness')).gitStatusQueryMock) vi.mock( - '../git/status', - async () => (await import('./filesystem-test-harness')).gitStatusModuleMock + '../git/submodule-status', + async () => (await import('./filesystem-test-harness')).gitSubmoduleStatusMock +) +vi.mock( + '../git/conflict-status', + async () => (await import('./filesystem-test-harness')).gitConflictStatusMock +) +vi.mock( + '../git/status-file-diff', + async () => (await import('./filesystem-test-harness')).gitFileDiffMock +) +vi.mock( + '../git/git-commit-operations', + async () => (await import('./filesystem-test-harness')).gitCommitOperationsMock +) +vi.mock( + '../git/git-index-mutations', + async () => (await import('./filesystem-test-harness')).gitIndexMutationsMock +) +vi.mock( + '../git/git-discard-changes', + async () => (await import('./filesystem-test-harness')).gitDiscardChangesMock +) +vi.mock( + '../git/branch-comparison', + async () => (await import('./filesystem-test-harness')).gitBranchComparisonMock +) +vi.mock( + '../git/commit-comparison', + async () => (await import('./filesystem-test-harness')).gitCommitComparisonMock ) vi.mock( '../git/check-ignored-paths', async () => (await import('./filesystem-test-harness')).gitIgnoredPathsMock ) -vi.mock('../git/worktree', async () => (await import('./filesystem-test-harness')).gitWorktreeMock) +vi.mock( + '../git/worktree-listing', + async () => (await import('./filesystem-test-harness')).gitWorktreeMock +) vi.mock( '../providers/ssh-filesystem-dispatch', async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock diff --git a/src/main/ipc/filesystem-pull-request-field-generation.test.ts b/src/main/ipc/filesystem-pull-request-field-generation.test.ts index a80b51b9c4e..1f41f8fcd8b 100644 --- a/src/main/ipc/filesystem-pull-request-field-generation.test.ts +++ b/src/main/ipc/filesystem-pull-request-field-generation.test.ts @@ -27,15 +27,47 @@ vi.mock( '../local-downloaded-folder-promotion', async () => (await import('./filesystem-test-harness')).folderPromotionMock ) +vi.mock('../git/status', async () => (await import('./filesystem-test-harness')).gitStatusQueryMock) vi.mock( - '../git/status', - async () => (await import('./filesystem-test-harness')).gitStatusModuleMock + '../git/submodule-status', + async () => (await import('./filesystem-test-harness')).gitSubmoduleStatusMock +) +vi.mock( + '../git/conflict-status', + async () => (await import('./filesystem-test-harness')).gitConflictStatusMock +) +vi.mock( + '../git/status-file-diff', + async () => (await import('./filesystem-test-harness')).gitFileDiffMock +) +vi.mock( + '../git/git-commit-operations', + async () => (await import('./filesystem-test-harness')).gitCommitOperationsMock +) +vi.mock( + '../git/git-index-mutations', + async () => (await import('./filesystem-test-harness')).gitIndexMutationsMock +) +vi.mock( + '../git/git-discard-changes', + async () => (await import('./filesystem-test-harness')).gitDiscardChangesMock +) +vi.mock( + '../git/branch-comparison', + async () => (await import('./filesystem-test-harness')).gitBranchComparisonMock +) +vi.mock( + '../git/commit-comparison', + async () => (await import('./filesystem-test-harness')).gitCommitComparisonMock ) vi.mock( '../git/check-ignored-paths', async () => (await import('./filesystem-test-harness')).gitIgnoredPathsMock ) -vi.mock('../git/worktree', async () => (await import('./filesystem-test-harness')).gitWorktreeMock) +vi.mock( + '../git/worktree-listing', + async () => (await import('./filesystem-test-harness')).gitWorktreeMock +) vi.mock( '../providers/ssh-filesystem-dispatch', async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock diff --git a/src/main/ipc/filesystem-search-git.ts b/src/main/ipc/filesystem-search-git.ts index 0bff6336f66..8b5e91c0494 100644 --- a/src/main/ipc/filesystem-search-git.ts +++ b/src/main/ipc/filesystem-search-git.ts @@ -7,7 +7,7 @@ import { ingestGitGrepLine, SEARCH_TIMEOUT_MS } from '../../shared/text-search' -import { gitSpawnAfterWindowsEnvironmentReady } from '../git/runner' +import { gitSpawnAfterWindowsEnvironmentReady } from '../git/git-process-launch' import { isWslLinkedWorktreeGitRoutingCandidate, prepareWslLinkedWorktreeGitRouting diff --git a/src/main/ipc/filesystem-test-harness.ts b/src/main/ipc/filesystem-test-harness.ts index 78f85f1dbcf..255d7d38fe5 100644 --- a/src/main/ipc/filesystem-test-harness.ts +++ b/src/main/ipc/filesystem-test-harness.ts @@ -33,6 +33,10 @@ export const abortRebaseMock: IpcMock = vi.fn() export const getDiffMock: IpcMock = vi.fn() export const getBranchCompareMock: IpcMock = vi.fn() export const getBranchDiffMock: IpcMock = vi.fn() +export const getCommitCompareMock: IpcMock = vi.fn() +export const getCommitDiffMock: IpcMock = vi.fn() +export const getSubmoduleStatusMock: IpcMock = vi.fn() +export const detectConflictOperationMock: IpcMock = vi.fn() export const getStagedCommitContextMock: IpcMock = vi.fn() export const stageFileMock: IpcMock = vi.fn() export const bulkStageFilesMock: IpcMock = vi.fn() @@ -101,6 +105,41 @@ export const gitStatusModuleMock = { bulkDiscardChanges: bulkDiscardChangesMock, discardChanges: discardChangesMock } +export const gitStatusQueryMock = { getStatus: getStatusMock } +export const gitSubmoduleStatusMock = { getSubmoduleStatus: getSubmoduleStatusMock } +export const gitConflictStatusMock = { + abortMerge: abortMergeMock, + abortRebase: abortRebaseMock, + detectConflictOperation: detectConflictOperationMock +} +export const gitFileDiffMock = { getDiff: getDiffMock } +export const gitCommitOperationsMock = { + commitChanges: commitChangesMock, + getStagedCommitContext: getStagedCommitContextMock +} +export const gitIndexMutationsMock = { + stageFile: stageFileMock, + bulkStageFiles: bulkStageFilesMock, + unstageFile: unstageFileMock, + bulkUnstageFiles: bulkUnstageFilesMock +} +export const gitDiscardChangesMock = { + bulkDiscardChanges: bulkDiscardChangesMock, + discardChanges: discardChangesMock +} +export const gitBranchComparisonMock = { + getBranchCompare: getBranchCompareMock, + getBranchDiff: getBranchDiffMock +} +export const gitCommitComparisonMock = { + getCommitCompare: getCommitCompareMock, + getCommitDiff: getCommitDiffMock +} +export const repoWorktreesMock = { + listRepoWorktrees: listWorktreesMock, + isRepoRoot: (repos: { path: string }[], targetPath: string) => + repos.some((repo) => path.resolve(repo.path) === path.resolve(targetPath)) +} export const gitIgnoredPathsMock = { checkIgnoredPaths: checkIgnoredPathsMock } diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index fe1568dbf72..5f4c78949ae 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -34,15 +34,47 @@ vi.mock( '../local-downloaded-folder-promotion', async () => (await import('./filesystem-test-harness')).folderPromotionMock ) +vi.mock('../git/status', async () => (await import('./filesystem-test-harness')).gitStatusQueryMock) vi.mock( - '../git/status', - async () => (await import('./filesystem-test-harness')).gitStatusModuleMock + '../git/submodule-status', + async () => (await import('./filesystem-test-harness')).gitSubmoduleStatusMock +) +vi.mock( + '../git/conflict-status', + async () => (await import('./filesystem-test-harness')).gitConflictStatusMock +) +vi.mock( + '../git/status-file-diff', + async () => (await import('./filesystem-test-harness')).gitFileDiffMock +) +vi.mock( + '../git/git-commit-operations', + async () => (await import('./filesystem-test-harness')).gitCommitOperationsMock +) +vi.mock( + '../git/git-index-mutations', + async () => (await import('./filesystem-test-harness')).gitIndexMutationsMock +) +vi.mock( + '../git/git-discard-changes', + async () => (await import('./filesystem-test-harness')).gitDiscardChangesMock +) +vi.mock( + '../git/branch-comparison', + async () => (await import('./filesystem-test-harness')).gitBranchComparisonMock +) +vi.mock( + '../git/commit-comparison', + async () => (await import('./filesystem-test-harness')).gitCommitComparisonMock ) vi.mock( '../git/check-ignored-paths', async () => (await import('./filesystem-test-harness')).gitIgnoredPathsMock ) -vi.mock('../git/worktree', async () => (await import('./filesystem-test-harness')).gitWorktreeMock) +vi.mock( + '../repo-worktrees', + async () => (await import('./filesystem-test-harness')).repoWorktreesMock +) vi.mock( '../providers/ssh-filesystem-dispatch', async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 0ad3825a7fc..020bbe37c1e 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -39,26 +39,20 @@ import { ingestRgJsonLine, SEARCH_TIMEOUT_MS } from '../../shared/text-search' +import { getStatus } from '../git/status' +import { getSubmoduleStatus } from '../git/submodule-status' +import { abortMerge, abortRebase, detectConflictOperation } from '../git/conflict-status' +import { getDiff } from '../git/status-file-diff' +import { commitChanges, getStagedCommitContext } from '../git/git-commit-operations' import { - getStatus, - getSubmoduleStatus, - abortMerge, - abortRebase, - detectConflictOperation, - getDiff, - commitChanges, stageFile, unstageFile, bulkStageFiles, - bulkUnstageFiles, - bulkDiscardChanges, - discardChanges, - getStagedCommitContext, - getBranchCompare, - getBranchDiff, - getCommitCompare, - getCommitDiff -} from '../git/status' + bulkUnstageFiles +} from '../git/git-index-mutations' +import { bulkDiscardChanges, discardChanges } from '../git/git-discard-changes' +import { getBranchCompare, getBranchDiff } from '../git/branch-comparison' +import { getCommitCompare, getCommitDiff } from '../git/commit-comparison' import { getHistory } from '../git/history' import { cancelGenerateCommitMessageLocal, @@ -89,7 +83,7 @@ import type { HostedReviewProvider } from '../../shared/hosted-review' import type { ResolvedSourceControlAiGenerationParams } from '../../shared/source-control-ai' import { withLinkedIssueDraftContext } from '../../shared/source-control-ai-action-variables' import { validateGitPushTarget } from '../git/push-target-validation' -import { getRemoteCommitUrl, getRemoteFileUrl } from '../git/repo' +import { getRemoteCommitUrl, getRemoteFileUrl } from '../git/repository-remotes' import { resolveAuthorizedPath, authorizeExternalPath } from './filesystem-auth' import { resolveRegisteredWorktreePath } from './registered-worktree-roots-cache' import { validateGitRelativeFilePath, isENOENT } from './filesystem-path-containment' diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index 7673aff06ce..0778e5cd967 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -61,9 +61,9 @@ import { clearVisiblePRRefreshWindow, enqueuePRRefresh, refreshPRNow, - reportVisiblePRRefreshCandidates, - setPRRefreshOutcomeObserver + reportVisiblePRRefreshCandidates } from '../github/pr-refresh-coordinator' +import { setRefreshOutcomeObserver } from '../github/pr-refresh-queue-state' import { getWorkItemDetails, getPRFileContents } from '../github/work-item-details' import { getRateLimit } from '../github/rate-limit' import { diagnoseGhAuth } from '../github/auth-diagnose' @@ -73,24 +73,29 @@ import { } from '../github/pr-refresh-validation-backoff' import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' import { dispatchWorkItem, type WorkItemArgs } from './github-work-item-args' +import { getProjectViewTable, listProjectViews } from '../github/project-view' +import { listAccessibleProjects } from '../github/project-view/accessible-project-discovery' +import { resolveProjectRef } from '../github/project-view/project-reference' +import { updateIssueBySlug } from '../github/project-view/mutations' import { - getProjectViewTable, - listAccessibleProjects, - resolveProjectRef, - listProjectViews, - getWorkItemDetailsBySlug, - updateProjectItemFieldValue, clearProjectItemFieldValue, - updateIssueBySlug, - updatePullRequestBySlug, + updateProjectItemFieldValue +} from '../github/project-view/project-item-field-mutations' +import { updatePullRequestBySlug } from '../github/project-view/pull-request-mutations' +import { addIssueCommentBySlug, - updateIssueCommentBySlug, deleteIssueCommentBySlug, - listLabelsBySlug, + updateIssueCommentBySlug +} from '../github/project-view/issue-comment-mutations' +import { listAssignableUsersBySlug, + listLabelsBySlug +} from '../github/project-view/repository-label-assignee-sources' +import { listIssueTypesBySlug, updateIssueTypeBySlug -} from '../github/project-view' +} from '../github/project-view/repository-issue-types' +import { getWorkItemDetailsBySlug } from '../github/project-view/work-item-details-query' import type { AddIssueCommentBySlugArgs, ClearProjectItemFieldArgs, @@ -252,7 +257,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi } } - setPRRefreshOutcomeObserver((candidate, outcome) => { + setRefreshOutcomeObserver((candidate, outcome) => { const repo = store.getRepos().find((r) => r.id === candidate.repoId) ?? store.getRepos().find((r) => resolve(r.path) === resolve(candidate.repoPath)) diff --git a/src/main/ipc/preflight-agent-refresh.test.ts b/src/main/ipc/preflight-agent-refresh.test.ts index fb2a8f0c99e..cc64e2beeca 100644 --- a/src/main/ipc/preflight-agent-refresh.test.ts +++ b/src/main/ipc/preflight-agent-refresh.test.ts @@ -130,7 +130,13 @@ describe('preflight', () => { }) runWslProcessMock.mockImplementation(async ({ script }: { script: string }) => { if (script.includes("'claude'")) { - return { environmentResolved: true, code: 0, stdout: '__ORCA_AGENT_PATH__claude\t/home/test/.local/bin/claude\n', stderr: '', timedOut: false } + return { + environmentResolved: true, + code: 0, + stdout: '__ORCA_AGENT_PATH__claude\t/home/test/.local/bin/claude\n', + stderr: '', + timedOut: false + } } throw new Error('not found') }) @@ -183,7 +189,13 @@ describe('preflight', () => { throw new Error(`unexpected command ${String(command)}`) } if (String(args[0]) === 'opencode') { - return { environmentResolved: true, code: 0, stdout: '/Users/test/.opencode/bin/opencode\n', stderr: '', timedOut: false } + return { + environmentResolved: true, + code: 0, + stdout: '/Users/test/.opencode/bin/opencode\n', + stderr: '', + timedOut: false + } } throw new Error('not found') }) @@ -219,7 +231,13 @@ describe('preflight', () => { throw new Error(`unexpected command ${String(command)}`) } if (String(args[0]) === 'claude') { - return { environmentResolved: true, code: 0, stdout: '/Users/test/.local/bin/claude\n', stderr: '', timedOut: false } + return { + environmentResolved: true, + code: 0, + stdout: '/Users/test/.local/bin/claude\n', + stderr: '', + timedOut: false + } } throw new Error('not found') }) diff --git a/src/main/ipc/repos-add-linked-worktree.test.ts b/src/main/ipc/repos-add-linked-worktree.test.ts index de9da98ae38..1f8bbaf2d26 100644 --- a/src/main/ipc/repos-add-linked-worktree.test.ts +++ b/src/main/ipc/repos-add-linked-worktree.test.ts @@ -47,9 +47,11 @@ vi.mock('../git/repo', () => ({ isGitRepo: isGitRepoMock, getGitRepoRoot: getGitRepoRootMock, getLinkedWorktreeMainRepoRoot: getLinkedWorktreeMainRepoRootMock, - getRepoName: vi.fn().mockImplementation((path: string) => path.split('/').pop()), - getBaseRefDefault: vi.fn().mockResolvedValue('origin/main'), - searchBaseRefs: vi.fn().mockResolvedValue([]) + getRepoName: vi.fn().mockImplementation((path: string) => path.split('/').pop()) +})) + +vi.mock('../git/default-base-ref', () => ({ + getBaseRefDefault: vi.fn().mockResolvedValue('origin/main') })) vi.mock('../repo-detection', () => ({ diff --git a/src/main/ipc/repos-create.test.ts b/src/main/ipc/repos-create.test.ts index be7f322a371..3d97ec33aa1 100644 --- a/src/main/ipc/repos-create.test.ts +++ b/src/main/ipc/repos-create.test.ts @@ -71,9 +71,11 @@ vi.mock('../git/runner', () => ({ vi.mock('../git/repo', () => ({ isGitRepo: vi.fn().mockReturnValue(true), - getRepoName: vi.fn().mockImplementation((path: string) => path.split('/').pop()), - getBaseRefDefault: vi.fn().mockResolvedValue('origin/main'), - searchBaseRefs: vi.fn().mockResolvedValue([]) + getRepoName: vi.fn().mockImplementation((path: string) => path.split('/').pop()) +})) + +vi.mock('../git/default-base-ref', () => ({ + getBaseRefDefault: vi.fn().mockResolvedValue('origin/main') })) vi.mock('./registered-worktree-roots-cache', () => ({ diff --git a/src/main/ipc/repos-execution-host-catalog.test.ts b/src/main/ipc/repos-execution-host-catalog.test.ts index 6d200522535..a57cf7c1014 100644 --- a/src/main/ipc/repos-execution-host-catalog.test.ts +++ b/src/main/ipc/repos-execution-host-catalog.test.ts @@ -1,6 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as GitRunner from '../git/runner' import type * as RepoModule from '../git/repo' +import type * as DefaultBaseRefModule from '../git/default-base-ref' +import type * as RepositoryRemotesModule from '../git/repository-remotes' const { reposMocks, moduleMocks } = await vi.hoisted(async () => { const moduleMocks = await import('./repos-remote-test-harness') @@ -11,6 +13,12 @@ vi.mock('electron', () => moduleMocks.electronModuleMock(reposMocks)) vi.mock('../git/repo', async (importOriginal) => moduleMocks.gitRepoModuleMock(await importOriginal()) ) +vi.mock('../git/default-base-ref', async (importOriginal) => + moduleMocks.defaultBaseRefModuleMock(await importOriginal()) +) +vi.mock('../git/repository-remotes', async (importOriginal) => + moduleMocks.repositoryRemotesModuleMock(await importOriginal()) +) vi.mock('../git/runner', async (importOriginal) => moduleMocks.gitRunnerModuleMock(reposMocks, await importOriginal()) ) diff --git a/src/main/ipc/repos-local-add-and-project-setup.test.ts b/src/main/ipc/repos-local-add-and-project-setup.test.ts index aed03724200..8eb689a471d 100644 --- a/src/main/ipc/repos-local-add-and-project-setup.test.ts +++ b/src/main/ipc/repos-local-add-and-project-setup.test.ts @@ -1,6 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as GitRunner from '../git/runner' import type * as RepoModule from '../git/repo' +import type * as DefaultBaseRefModule from '../git/default-base-ref' +import type * as RepositoryRemotesModule from '../git/repository-remotes' const { reposMocks, moduleMocks } = await vi.hoisted(async () => { const moduleMocks = await import('./repos-remote-test-harness') @@ -11,6 +13,12 @@ vi.mock('electron', () => moduleMocks.electronModuleMock(reposMocks)) vi.mock('../git/repo', async (importOriginal) => moduleMocks.gitRepoModuleMock(await importOriginal()) ) +vi.mock('../git/default-base-ref', async (importOriginal) => + moduleMocks.defaultBaseRefModuleMock(await importOriginal()) +) +vi.mock('../git/repository-remotes', async (importOriginal) => + moduleMocks.repositoryRemotesModuleMock(await importOriginal()) +) vi.mock('../git/runner', async (importOriginal) => moduleMocks.gitRunnerModuleMock(reposMocks, await importOriginal()) ) diff --git a/src/main/ipc/repos-local-clone-lifecycle.test.ts b/src/main/ipc/repos-local-clone-lifecycle.test.ts index b6f851590ba..ea21a2a85c8 100644 --- a/src/main/ipc/repos-local-clone-lifecycle.test.ts +++ b/src/main/ipc/repos-local-clone-lifecycle.test.ts @@ -4,7 +4,10 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import type * as GitRunner from '../git/runner' +import type * as GitProcessLaunch from '../git/git-process-launch' import type * as RepoModule from '../git/repo' +import type * as DefaultBaseRefModule from '../git/default-base-ref' +import type * as RepositoryRemotesModule from '../git/repository-remotes' const { reposMocks, moduleMocks } = await vi.hoisted(async () => { const moduleMocks = await import('./repos-remote-test-harness') @@ -15,9 +18,21 @@ vi.mock('electron', () => moduleMocks.electronModuleMock(reposMocks)) vi.mock('../git/repo', async (importOriginal) => moduleMocks.gitRepoModuleMock(await importOriginal()) ) +vi.mock('../git/default-base-ref', async (importOriginal) => + moduleMocks.defaultBaseRefModuleMock(await importOriginal()) +) +vi.mock('../git/repository-remotes', async (importOriginal) => + moduleMocks.repositoryRemotesModuleMock(await importOriginal()) +) vi.mock('../git/runner', async (importOriginal) => moduleMocks.gitRunnerModuleMock(reposMocks, await importOriginal()) ) +vi.mock('../git/git-process-launch', async (importOriginal) => + moduleMocks.gitProcessLaunchModuleMock( + reposMocks, + await importOriginal() + ) +) vi.mock('../git/worktree', () => moduleMocks.gitWorktreeModuleMock(reposMocks)) vi.mock('./registered-worktree-roots-cache', () => moduleMocks.registeredWorktreeRootsCacheModuleMock(reposMocks) @@ -36,7 +51,7 @@ import { registerRepoHandlers } from './repos' import { clearGitCapabilityStateForTests } from '../git/git-capability-state' import { resetSshProviderAuthorities } from '../ssh/ssh-provider-authority' import { DEFAULT_REPO_BADGE_COLOR } from '../../shared/constants' -import { listSubmodulePaths } from '../git/status' +import { listSubmodulePaths } from '../git/submodule-paths' import { createMockCloneProcess, createRepoHandlerHarness, diff --git a/src/main/ipc/repos-nested-import.test.ts b/src/main/ipc/repos-nested-import.test.ts index 010be624a91..e9ff6a834f3 100644 --- a/src/main/ipc/repos-nested-import.test.ts +++ b/src/main/ipc/repos-nested-import.test.ts @@ -4,6 +4,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import type * as GitRunner from '../git/runner' import type * as RepoModule from '../git/repo' +import type * as DefaultBaseRefModule from '../git/default-base-ref' +import type * as RepositoryRemotesModule from '../git/repository-remotes' const { reposMocks, moduleMocks } = await vi.hoisted(async () => { const moduleMocks = await import('./repos-remote-test-harness') @@ -14,10 +16,16 @@ vi.mock('electron', () => moduleMocks.electronModuleMock(reposMocks)) vi.mock('../git/repo', async (importOriginal) => moduleMocks.gitRepoModuleMock(await importOriginal()) ) +vi.mock('../git/default-base-ref', async (importOriginal) => + moduleMocks.defaultBaseRefModuleMock(await importOriginal()) +) +vi.mock('../git/repository-remotes', async (importOriginal) => + moduleMocks.repositoryRemotesModuleMock(await importOriginal()) +) vi.mock('../git/runner', async (importOriginal) => moduleMocks.gitRunnerModuleMock(reposMocks, await importOriginal()) ) -vi.mock('../git/worktree', () => moduleMocks.gitWorktreeModuleMock(reposMocks)) +vi.mock('../git/worktree-listing', () => moduleMocks.gitWorktreeModuleMock(reposMocks)) vi.mock('./registered-worktree-roots-cache', () => moduleMocks.registeredWorktreeRootsCacheModuleMock(reposMocks) ) diff --git a/src/main/ipc/repos-nested-scan.test.ts b/src/main/ipc/repos-nested-scan.test.ts index 26650246a06..56168e658f8 100644 --- a/src/main/ipc/repos-nested-scan.test.ts +++ b/src/main/ipc/repos-nested-scan.test.ts @@ -4,6 +4,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import type * as GitRunner from '../git/runner' import type * as RepoModule from '../git/repo' +import type * as DefaultBaseRefModule from '../git/default-base-ref' +import type * as RepositoryRemotesModule from '../git/repository-remotes' const { reposMocks, moduleMocks } = await vi.hoisted(async () => { const moduleMocks = await import('./repos-remote-test-harness') @@ -14,6 +16,12 @@ vi.mock('electron', () => moduleMocks.electronModuleMock(reposMocks)) vi.mock('../git/repo', async (importOriginal) => moduleMocks.gitRepoModuleMock(await importOriginal()) ) +vi.mock('../git/default-base-ref', async (importOriginal) => + moduleMocks.defaultBaseRefModuleMock(await importOriginal()) +) +vi.mock('../git/repository-remotes', async (importOriginal) => + moduleMocks.repositoryRemotesModuleMock(await importOriginal()) +) vi.mock('../git/runner', async (importOriginal) => moduleMocks.gitRunnerModuleMock(reposMocks, await importOriginal()) ) diff --git a/src/main/ipc/repos-picker.test.ts b/src/main/ipc/repos-picker.test.ts index 0e843f31e95..a8f755ec21c 100644 --- a/src/main/ipc/repos-picker.test.ts +++ b/src/main/ipc/repos-picker.test.ts @@ -22,9 +22,11 @@ vi.mock('../git/runner', () => ({ vi.mock('../git/repo', () => ({ isGitRepo: vi.fn(), - getRepoName: vi.fn(), - getBaseRefDefault: vi.fn(), - searchBaseRefs: vi.fn() + getRepoName: vi.fn() +})) + +vi.mock('../git/default-base-ref', () => ({ + getBaseRefDefault: vi.fn() })) vi.mock('./registered-worktree-roots-cache', () => ({ diff --git a/src/main/ipc/repos-remote-base-ref-queries.test.ts b/src/main/ipc/repos-remote-base-ref-queries.test.ts index f96ff9c4da6..0b1534cac83 100644 --- a/src/main/ipc/repos-remote-base-ref-queries.test.ts +++ b/src/main/ipc/repos-remote-base-ref-queries.test.ts @@ -1,6 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as GitRunner from '../git/runner' import type * as RepoModule from '../git/repo' +import type * as DefaultBaseRefModule from '../git/default-base-ref' +import type * as RepositoryRemotesModule from '../git/repository-remotes' const { reposMocks, moduleMocks } = await vi.hoisted(async () => { const moduleMocks = await import('./repos-remote-test-harness') @@ -11,6 +13,12 @@ vi.mock('electron', () => moduleMocks.electronModuleMock(reposMocks)) vi.mock('../git/repo', async (importOriginal) => moduleMocks.gitRepoModuleMock(await importOriginal()) ) +vi.mock('../git/default-base-ref', async (importOriginal) => + moduleMocks.defaultBaseRefModuleMock(await importOriginal()) +) +vi.mock('../git/repository-remotes', async (importOriginal) => + moduleMocks.repositoryRemotesModuleMock(await importOriginal()) +) vi.mock('../git/runner', async (importOriginal) => moduleMocks.gitRunnerModuleMock(reposMocks, await importOriginal()) ) diff --git a/src/main/ipc/repos-remote-client-events.test.ts b/src/main/ipc/repos-remote-client-events.test.ts index 45151bf205e..ef6fc8ac7de 100644 --- a/src/main/ipc/repos-remote-client-events.test.ts +++ b/src/main/ipc/repos-remote-client-events.test.ts @@ -28,11 +28,11 @@ vi.mock('electron', () => ({ vi.mock('../git/repo', () => ({ isGitRepo: vi.fn().mockReturnValue(true), - getRepoName: vi.fn().mockImplementation((path: string) => path.split('/').pop()), - getBaseRefDefault: vi.fn().mockResolvedValue('origin/main'), - searchBaseRefs: vi.fn().mockResolvedValue([]), - BASE_REF_SEARCH_ARGS: ['for-each-ref'], - filterBaseRefSearchOutput: vi.fn().mockReturnValue([]) + getRepoName: vi.fn().mockImplementation((path: string) => path.split('/').pop()) +})) + +vi.mock('../git/default-base-ref', () => ({ + getBaseRefDefault: vi.fn().mockResolvedValue('origin/main') })) vi.mock('./registered-worktree-roots-cache', () => ({ invalidateAuthorizedRootsCache: vi.fn() })) diff --git a/src/main/ipc/repos-remote-git-username.test.ts b/src/main/ipc/repos-remote-git-username.test.ts index d3f22254fd7..3c0a27409c8 100644 --- a/src/main/ipc/repos-remote-git-username.test.ts +++ b/src/main/ipc/repos-remote-git-username.test.ts @@ -1,6 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as GitRunner from '../git/runner' import type * as RepoModule from '../git/repo' +import type * as DefaultBaseRefModule from '../git/default-base-ref' +import type * as RepositoryRemotesModule from '../git/repository-remotes' const { reposMocks, moduleMocks } = await vi.hoisted(async () => { const moduleMocks = await import('./repos-remote-test-harness') @@ -11,6 +13,12 @@ vi.mock('electron', () => moduleMocks.electronModuleMock(reposMocks)) vi.mock('../git/repo', async (importOriginal) => moduleMocks.gitRepoModuleMock(await importOriginal()) ) +vi.mock('../git/default-base-ref', async (importOriginal) => + moduleMocks.defaultBaseRefModuleMock(await importOriginal()) +) +vi.mock('../git/repository-remotes', async (importOriginal) => + moduleMocks.repositoryRemotesModuleMock(await importOriginal()) +) vi.mock('../git/runner', async (importOriginal) => moduleMocks.gitRunnerModuleMock(reposMocks, await importOriginal()) ) diff --git a/src/main/ipc/repos-remote-test-harness.ts b/src/main/ipc/repos-remote-test-harness.ts index 4669e9f9900..249779d9d50 100644 --- a/src/main/ipc/repos-remote-test-harness.ts +++ b/src/main/ipc/repos-remote-test-harness.ts @@ -2,7 +2,10 @@ import { EventEmitter } from 'node:events' import { vi } from 'vitest' import type { Mock } from 'vitest' import type * as GitRunner from '../git/runner' +import type * as GitProcessLaunch from '../git/git-process-launch' import type * as RepoModule from '../git/repo' +import type * as DefaultBaseRefModule from '../git/default-base-ref' +import type * as RepositoryRemotesModule from '../git/repository-remotes' /** Unconstrained spy; annotated explicitly so declaration emit never names @vitest/spy internals. */ export type ReposIpcSpy = Mock<(...args: never[]) => unknown> @@ -111,20 +114,27 @@ export function electronModuleMock(mocks: ReposIpcMocks): Record { return { ...actual, - // Stub only the functions that spawn git / touch the filesystem. isGitRepo: vi.fn().mockReturnValue(true), getGitRepoRoot: vi.fn((path: string) => path), - getRepoName: vi.fn().mockImplementation((path: string) => path.split('/').pop()), - getBaseRefDefault: vi.fn().mockResolvedValue('origin/main'), - getRemoteCount: vi.fn().mockResolvedValue(1), - searchBaseRefs: vi.fn().mockResolvedValue([]) + getRepoName: vi.fn().mockImplementation((path: string) => path.split('/').pop()) } } +export function defaultBaseRefModuleMock( + actual: typeof DefaultBaseRefModule +): Record { + return { ...actual, getBaseRefDefault: vi.fn().mockResolvedValue('origin/main') } +} + +export function repositoryRemotesModuleMock( + actual: typeof RepositoryRemotesModule +): Record { + return { ...actual, getRemoteCount: vi.fn().mockResolvedValue(1) } +} + // Why: keep the real env builders so the clone regression test (#7652) asserts real markers, not a mock echoing itself. export function gitRunnerModuleMock( mocks: ReposIpcMocks, @@ -133,8 +143,16 @@ export function gitRunnerModuleMock( return { ...actual, gitExecFileAsync: mocks.gitExecFileAsyncMock, - gitExecFileAsyncBuffer: vi.fn(), - gitStreamStdout: vi.fn(), + gitExecFileAsyncBuffer: vi.fn() + } +} + +export function gitProcessLaunchModuleMock( + mocks: ReposIpcMocks, + actual: typeof GitProcessLaunch +): Record { + return { + ...actual, gitSpawn: mocks.gitSpawnMock, gitSpawnAfterWindowsEnvironmentReady: mocks.gitSpawnAfterWindowsEnvironmentReadyMock } diff --git a/src/main/ipc/repos-remote.test.ts b/src/main/ipc/repos-remote.test.ts index e5660eb8d27..dc1f83c3e5e 100644 --- a/src/main/ipc/repos-remote.test.ts +++ b/src/main/ipc/repos-remote.test.ts @@ -2,6 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { EventEmitter } from 'node:events' import type * as GitRunner from '../git/runner' import type * as RepoModule from '../git/repo' +import type * as DefaultBaseRefModule from '../git/default-base-ref' +import type * as RepositoryRemotesModule from '../git/repository-remotes' const { reposMocks, moduleMocks } = await vi.hoisted(async () => { const moduleMocks = await import('./repos-remote-test-harness') @@ -12,6 +14,12 @@ vi.mock('electron', () => moduleMocks.electronModuleMock(reposMocks)) vi.mock('../git/repo', async (importOriginal) => moduleMocks.gitRepoModuleMock(await importOriginal()) ) +vi.mock('../git/default-base-ref', async (importOriginal) => + moduleMocks.defaultBaseRefModuleMock(await importOriginal()) +) +vi.mock('../git/repository-remotes', async (importOriginal) => + moduleMocks.repositoryRemotesModuleMock(await importOriginal()) +) vi.mock('../git/runner', async (importOriginal) => moduleMocks.gitRunnerModuleMock(reposMocks, await importOriginal()) ) @@ -33,7 +41,7 @@ import { registerRepoHandlers } from './repos' import { clearGitCapabilityStateForTests } from '../git/git-capability-state' import { resetSshProviderAuthorities } from '../ssh/ssh-provider-authority' import { DEFAULT_REPO_BADGE_COLOR } from '../../shared/constants' -import { clearSubmodulePathsCacheForTests } from '../git/status' +import { clearSubmodulePathsCacheForTests } from '../git/submodule-paths' import { createRepoHandlerHarness, waitForAssertion } from './repos-remote-test-harness' const { diff --git a/src/main/ipc/repos-sparse-presets.test.ts b/src/main/ipc/repos-sparse-presets.test.ts index 8de69f63255..0cd51f5e700 100644 --- a/src/main/ipc/repos-sparse-presets.test.ts +++ b/src/main/ipc/repos-sparse-presets.test.ts @@ -35,11 +35,11 @@ vi.mock('electron', () => ({ vi.mock('../git/repo', () => ({ isGitRepo: vi.fn().mockReturnValue(true), - getRepoName: vi.fn().mockImplementation((path: string) => path.split('/').pop()), - getBaseRefDefault: vi.fn().mockResolvedValue('origin/main'), - searchBaseRefs: vi.fn().mockResolvedValue([]), - BASE_REF_SEARCH_ARGS: ['for-each-ref'], - filterBaseRefSearchOutput: vi.fn().mockReturnValue([]) + getRepoName: vi.fn().mockImplementation((path: string) => path.split('/').pop()) +})) + +vi.mock('../git/default-base-ref', () => ({ + getBaseRefDefault: vi.fn().mockResolvedValue('origin/main') })) vi.mock('./registered-worktree-roots-cache', () => ({ diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index d4a9fc7f0ab..6c5a36bad4c 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -45,12 +45,9 @@ import { DiffCommentSchema } from '../../shared/diff-comment-schema' import { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cache' import type { ChildProcess } from 'node:child_process' import { access, mkdir, readdir, rm } from 'node:fs/promises' -import { - awaitWindowsHostGitEnvironmentReady, - gitExecFileAsync, - gitSpawnAfterWindowsEnvironmentReady, - nonInteractiveGitEnv -} from '../git/runner' +import { awaitWindowsHostGitEnvironmentReady, gitExecFileAsync } from '../git/runner' +import { gitSpawnAfterWindowsEnvironmentReady } from '../git/git-process-launch' +import { nonInteractiveGitEnv } from '../git/git-environment-policy' import { isAbsolute, join, posix } from 'node:path' import { cleanupClaimedCloneTarget, @@ -66,22 +63,17 @@ import { resolveNestedRepoSelection } from '../project-groups/nested-repo-import' import { createNestedRepoImportTargetResolver } from '../project-groups/nested-repo-import-target' +import { isGitRepo, getGitRepoRoot, getLinkedWorktreeMainRepoRoot, getRepoName } from '../git/repo' +import { getBaseRefDefault, resolveDefaultBaseRefViaExec } from '../git/default-base-ref' +import { getRemoteCount, parseRemoteCount } from '../git/repository-remotes' import { - isGitRepo, - getGitRepoRoot, - getLinkedWorktreeMainRepoRoot, - getRepoName, - getBaseRefDefault, - getRemoteCount, normalizeRefSearchQuery, parseAndFilterSearchRefDetails, - parseRemoteCount, - resolveDefaultBaseRefViaExec, buildSearchBaseRefsArgv, - isForEachRefExcludeUnsupportedError, mergeBaseRefSearchResultGroups, searchBaseRefDetails -} from '../git/repo' +} from '../git/base-ref-search' +import { isForEachRefExcludeUnsupportedError } from '../../shared/git-ref-command-capabilities' import { getSshGitProvider } from '../providers/ssh-git-dispatch' import { getSshGitCapabilityCache } from '../git/git-capability-state' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' @@ -123,7 +115,7 @@ import { normalizeCustomWorktreeVisibilitySources, normalizeWorktreeVisibilitySourcePreferences } from '../../shared/worktree/visibility-sources' -import { runWithGitReadCacheInvalidation } from '../git/status' +import { runWithGitReadCacheInvalidation } from '../git/git-read-cache' import { isAdmissibleDirectSshAuthority } from '../../shared/ssh-retained-payload-admission' import { isCurrentSshProviderAuthority } from '../ssh/ssh-provider-authority' diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 1d63cf97deb..60c50bd5ee0 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -26,13 +26,14 @@ import type { WorktreeHeadIdentity } from '../../shared/worktree/types' import { getPRForBranch } from '../github/client' -import { listWorktrees, addWorktree, addSparseWorktree } from '../git/worktree' -import type { AddWorktreeOptions, AddWorktreeResult } from '../git/worktree' +import { addSparseWorktree, addWorktree } from '../git/worktree' +import { listWorktrees } from '../git/worktree-listing' +import type { AddWorktreeOptions, AddWorktreeResult } from '../git/worktree-operation-contracts' +import { getBranchConflictKind } from '../git/branch-conflict-detection' import { - getBranchConflictKind, resolveDefaultBaseRefViaExec, resolveDefaultBaseRefWithLocalGit -} from '../git/repo' +} from '../git/default-base-ref' import { resolveLocalGitUsername, getSshGitUsername } from '../git/git-username' import { hasCommitObjectViaGitExec } from '../git/commit-object-ref' import { probeWorktreeBaseRefPresence } from '../git/worktree-base-ref-probe' diff --git a/src/main/ipc/worktrees-create-metadata-persistence.test.ts b/src/main/ipc/worktrees-create-metadata-persistence.test.ts index 798cc443442..bf2ccdf2ebf 100644 --- a/src/main/ipc/worktrees-create-metadata-persistence.test.ts +++ b/src/main/ipc/worktrees-create-metadata-persistence.test.ts @@ -20,11 +20,26 @@ vi.mock('electron', async () => vi.mock('../git/worktree', async () => (await import('./worktrees-test-module-mocks')).gitWorktreeModuleMock() ) +vi.mock('../git/worktree-listing', async () => + (await import('./worktrees-test-module-mocks')).gitWorktreeListingModuleMock() +) +vi.mock('../git/worktree-removal-preflight', async () => + (await import('./worktrees-test-module-mocks')).gitWorktreeRemovalPreflightModuleMock() +) +vi.mock('../git/worktree-branch-cleanup', async () => + (await import('./worktrees-test-module-mocks')).gitWorktreeBranchCleanupModuleMock() +) vi.mock('../git/runner', async () => (await import('./worktrees-test-module-mocks')).gitRunnerModuleMock() ) -vi.mock('../git/repo', async () => - (await import('./worktrees-test-module-mocks')).gitRepoModuleMock() +vi.mock('../git/default-base-ref', async () => + (await import('./worktrees-test-module-mocks')).defaultBaseRefModuleMock() +) +vi.mock('../git/repository-remotes', async () => + (await import('./worktrees-test-module-mocks')).repositoryRemotesModuleMock() +) +vi.mock('../git/branch-conflict-detection', async () => + (await import('./worktrees-test-module-mocks')).branchConflictDetectionModuleMock() ) vi.mock('../git/git-username', async (importOriginal) => ({ ...(await importOriginal>()), diff --git a/src/main/ipc/worktrees-delete-pty-teardown.test.ts b/src/main/ipc/worktrees-delete-pty-teardown.test.ts index ea398ddaf63..55c3c90155a 100644 --- a/src/main/ipc/worktrees-delete-pty-teardown.test.ts +++ b/src/main/ipc/worktrees-delete-pty-teardown.test.ts @@ -22,11 +22,26 @@ vi.mock('electron', async () => vi.mock('../git/worktree', async () => (await import('./worktrees-test-module-mocks')).gitWorktreeModuleMock() ) +vi.mock('../git/worktree-listing', async () => + (await import('./worktrees-test-module-mocks')).gitWorktreeListingModuleMock() +) +vi.mock('../git/worktree-removal-preflight', async () => + (await import('./worktrees-test-module-mocks')).gitWorktreeRemovalPreflightModuleMock() +) +vi.mock('../git/worktree-branch-cleanup', async () => + (await import('./worktrees-test-module-mocks')).gitWorktreeBranchCleanupModuleMock() +) vi.mock('../git/runner', async () => (await import('./worktrees-test-module-mocks')).gitRunnerModuleMock() ) -vi.mock('../git/repo', async () => - (await import('./worktrees-test-module-mocks')).gitRepoModuleMock() +vi.mock('../git/default-base-ref', async () => + (await import('./worktrees-test-module-mocks')).defaultBaseRefModuleMock() +) +vi.mock('../git/repository-remotes', async () => + (await import('./worktrees-test-module-mocks')).repositoryRemotesModuleMock() +) +vi.mock('../git/branch-conflict-detection', async () => + (await import('./worktrees-test-module-mocks')).branchConflictDetectionModuleMock() ) vi.mock('../git/git-username', async (importOriginal) => ({ ...(await importOriginal>()), diff --git a/src/main/ipc/worktrees-detected-scan-cache.test.ts b/src/main/ipc/worktrees-detected-scan-cache.test.ts index 6fb798fad3f..62fa4b9f2b1 100644 --- a/src/main/ipc/worktrees-detected-scan-cache.test.ts +++ b/src/main/ipc/worktrees-detected-scan-cache.test.ts @@ -14,11 +14,26 @@ vi.mock('electron', async () => vi.mock('../git/worktree', async () => (await import('./worktrees-test-module-mocks')).gitWorktreeModuleMock() ) +vi.mock('../git/worktree-listing', async () => + (await import('./worktrees-test-module-mocks')).gitWorktreeListingModuleMock() +) +vi.mock('../git/worktree-removal-preflight', async () => + (await import('./worktrees-test-module-mocks')).gitWorktreeRemovalPreflightModuleMock() +) +vi.mock('../git/worktree-branch-cleanup', async () => + (await import('./worktrees-test-module-mocks')).gitWorktreeBranchCleanupModuleMock() +) vi.mock('../git/runner', async () => (await import('./worktrees-test-module-mocks')).gitRunnerModuleMock() ) -vi.mock('../git/repo', async () => - (await import('./worktrees-test-module-mocks')).gitRepoModuleMock() +vi.mock('../git/default-base-ref', async () => + (await import('./worktrees-test-module-mocks')).defaultBaseRefModuleMock() +) +vi.mock('../git/repository-remotes', async () => + (await import('./worktrees-test-module-mocks')).repositoryRemotesModuleMock() +) +vi.mock('../git/branch-conflict-detection', async () => + (await import('./worktrees-test-module-mocks')).branchConflictDetectionModuleMock() ) vi.mock('../git/git-username', async (importOriginal) => ({ ...(await importOriginal>()), diff --git a/src/main/ipc/worktrees-discovery-metadata-backfill.test.ts b/src/main/ipc/worktrees-discovery-metadata-backfill.test.ts index b1fea8d41a5..aab4aa5415b 100644 --- a/src/main/ipc/worktrees-discovery-metadata-backfill.test.ts +++ b/src/main/ipc/worktrees-discovery-metadata-backfill.test.ts @@ -9,11 +9,26 @@ vi.mock('electron', async () => vi.mock('../git/worktree', async () => (await import('./worktrees-test-module-mocks')).gitWorktreeModuleMock() ) +vi.mock('../git/worktree-listing', async () => + (await import('./worktrees-test-module-mocks')).gitWorktreeListingModuleMock() +) +vi.mock('../git/worktree-removal-preflight', async () => + (await import('./worktrees-test-module-mocks')).gitWorktreeRemovalPreflightModuleMock() +) +vi.mock('../git/worktree-branch-cleanup', async () => + (await import('./worktrees-test-module-mocks')).gitWorktreeBranchCleanupModuleMock() +) vi.mock('../git/runner', async () => (await import('./worktrees-test-module-mocks')).gitRunnerModuleMock() ) -vi.mock('../git/repo', async () => - (await import('./worktrees-test-module-mocks')).gitRepoModuleMock() +vi.mock('../git/default-base-ref', async () => + (await import('./worktrees-test-module-mocks')).defaultBaseRefModuleMock() +) +vi.mock('../git/repository-remotes', async () => + (await import('./worktrees-test-module-mocks')).repositoryRemotesModuleMock() +) +vi.mock('../git/branch-conflict-detection', async () => + (await import('./worktrees-test-module-mocks')).branchConflictDetectionModuleMock() ) vi.mock('../git/git-username', async (importOriginal) => ({ ...(await importOriginal>()), diff --git a/src/main/ipc/worktrees-existing-branch-checkout.test.ts b/src/main/ipc/worktrees-existing-branch-checkout.test.ts index e455e176cf5..35146c27df8 100644 --- a/src/main/ipc/worktrees-existing-branch-checkout.test.ts +++ b/src/main/ipc/worktrees-existing-branch-checkout.test.ts @@ -17,11 +17,26 @@ vi.mock('electron', async () => vi.mock('../git/worktree', async () => (await import('./worktrees-test-module-mocks')).gitWorktreeModuleMock() ) +vi.mock('../git/worktree-listing', async () => + (await import('./worktrees-test-module-mocks')).gitWorktreeListingModuleMock() +) +vi.mock('../git/worktree-removal-preflight', async () => + (await import('./worktrees-test-module-mocks')).gitWorktreeRemovalPreflightModuleMock() +) +vi.mock('../git/worktree-branch-cleanup', async () => + (await import('./worktrees-test-module-mocks')).gitWorktreeBranchCleanupModuleMock() +) vi.mock('../git/runner', async () => (await import('./worktrees-test-module-mocks')).gitRunnerModuleMock() ) -vi.mock('../git/repo', async () => - (await import('./worktrees-test-module-mocks')).gitRepoModuleMock() +vi.mock('../git/default-base-ref', async () => + (await import('./worktrees-test-module-mocks')).defaultBaseRefModuleMock() +) +vi.mock('../git/repository-remotes', async () => + (await import('./worktrees-test-module-mocks')).repositoryRemotesModuleMock() +) +vi.mock('../git/branch-conflict-detection', async () => + (await import('./worktrees-test-module-mocks')).branchConflictDetectionModuleMock() ) vi.mock('../git/git-username', async (importOriginal) => ({ ...(await importOriginal>()), diff --git a/src/main/ipc/worktrees-forget-local.test.ts b/src/main/ipc/worktrees-forget-local.test.ts index b1616f6eecc..33db14ebdfc 100644 --- a/src/main/ipc/worktrees-forget-local.test.ts +++ b/src/main/ipc/worktrees-forget-local.test.ts @@ -28,8 +28,14 @@ vi.mock('../git/worktree', async () => vi.mock('../git/runner', async () => (await import('./worktrees-test-module-mocks')).gitRunnerModuleMock() ) -vi.mock('../git/repo', async () => - (await import('./worktrees-test-module-mocks')).gitRepoModuleMock() +vi.mock('../git/default-base-ref', async () => + (await import('./worktrees-test-module-mocks')).defaultBaseRefModuleMock() +) +vi.mock('../git/repository-remotes', async () => + (await import('./worktrees-test-module-mocks')).repositoryRemotesModuleMock() +) +vi.mock('../git/branch-conflict-detection', async () => + (await import('./worktrees-test-module-mocks')).branchConflictDetectionModuleMock() ) vi.mock('../git/git-username', async (importOriginal) => ({ ...(await importOriginal>()), diff --git a/src/main/ipc/worktrees-issue-command-overrides.test.ts b/src/main/ipc/worktrees-issue-command-overrides.test.ts index ae6e89d51e1..a6c4392db1a 100644 --- a/src/main/ipc/worktrees-issue-command-overrides.test.ts +++ b/src/main/ipc/worktrees-issue-command-overrides.test.ts @@ -14,8 +14,14 @@ vi.mock('../git/worktree', async () => vi.mock('../git/runner', async () => (await import('./worktrees-test-module-mocks')).gitRunnerModuleMock() ) -vi.mock('../git/repo', async () => - (await import('./worktrees-test-module-mocks')).gitRepoModuleMock() +vi.mock('../git/default-base-ref', async () => + (await import('./worktrees-test-module-mocks')).defaultBaseRefModuleMock() +) +vi.mock('../git/repository-remotes', async () => + (await import('./worktrees-test-module-mocks')).repositoryRemotesModuleMock() +) +vi.mock('../git/branch-conflict-detection', async () => + (await import('./worktrees-test-module-mocks')).branchConflictDetectionModuleMock() ) vi.mock('../git/git-username', async (importOriginal) => ({ ...(await importOriginal>()), diff --git a/src/main/ipc/worktrees-lineage-hydration.test.ts b/src/main/ipc/worktrees-lineage-hydration.test.ts index cb12b0095f9..cc6d0506337 100644 --- a/src/main/ipc/worktrees-lineage-hydration.test.ts +++ b/src/main/ipc/worktrees-lineage-hydration.test.ts @@ -14,11 +14,26 @@ vi.mock('electron', async () => vi.mock('../git/worktree', async () => (await import('./worktrees-test-module-mocks')).gitWorktreeModuleMock() ) +vi.mock('../git/worktree-listing', async () => + (await import('./worktrees-test-module-mocks')).gitWorktreeListingModuleMock() +) +vi.mock('../git/worktree-removal-preflight', async () => + (await import('./worktrees-test-module-mocks')).gitWorktreeRemovalPreflightModuleMock() +) +vi.mock('../git/worktree-branch-cleanup', async () => + (await import('./worktrees-test-module-mocks')).gitWorktreeBranchCleanupModuleMock() +) vi.mock('../git/runner', async () => (await import('./worktrees-test-module-mocks')).gitRunnerModuleMock() ) -vi.mock('../git/repo', async () => - (await import('./worktrees-test-module-mocks')).gitRepoModuleMock() +vi.mock('../git/default-base-ref', async () => + (await import('./worktrees-test-module-mocks')).defaultBaseRefModuleMock() +) +vi.mock('../git/repository-remotes', async () => + (await import('./worktrees-test-module-mocks')).repositoryRemotesModuleMock() +) +vi.mock('../git/branch-conflict-detection', async () => + (await import('./worktrees-test-module-mocks')).branchConflictDetectionModuleMock() ) vi.mock('../git/git-username', async (importOriginal) => ({ ...(await importOriginal>()), diff --git a/src/main/ipc/worktrees-listing-fallback-rows.test.ts b/src/main/ipc/worktrees-listing-fallback-rows.test.ts index 45bea853e67..02948bd7752 100644 --- a/src/main/ipc/worktrees-listing-fallback-rows.test.ts +++ b/src/main/ipc/worktrees-listing-fallback-rows.test.ts @@ -9,11 +9,26 @@ vi.mock('electron', async () => vi.mock('../git/worktree', async () => (await import('./worktrees-test-module-mocks')).gitWorktreeModuleMock() ) +vi.mock('../git/worktree-listing', async () => + (await import('./worktrees-test-module-mocks')).gitWorktreeListingModuleMock() +) +vi.mock('../git/worktree-removal-preflight', async () => + (await import('./worktrees-test-module-mocks')).gitWorktreeRemovalPreflightModuleMock() +) +vi.mock('../git/worktree-branch-cleanup', async () => + (await import('./worktrees-test-module-mocks')).gitWorktreeBranchCleanupModuleMock() +) vi.mock('../git/runner', async () => (await import('./worktrees-test-module-mocks')).gitRunnerModuleMock() ) -vi.mock('../git/repo', async () => - (await import('./worktrees-test-module-mocks')).gitRepoModuleMock() +vi.mock('../git/default-base-ref', async () => + (await import('./worktrees-test-module-mocks')).defaultBaseRefModuleMock() +) +vi.mock('../git/repository-remotes', async () => + (await import('./worktrees-test-module-mocks')).repositoryRemotesModuleMock() +) +vi.mock('../git/branch-conflict-detection', async () => + (await import('./worktrees-test-module-mocks')).branchConflictDetectionModuleMock() ) vi.mock('../git/git-username', async (importOriginal) => ({ ...(await importOriginal>()), diff --git a/src/main/ipc/worktrees-local-base-ref-resolution.test.ts b/src/main/ipc/worktrees-local-base-ref-resolution.test.ts index 130816d901c..26309b1df08 100644 --- a/src/main/ipc/worktrees-local-base-ref-resolution.test.ts +++ b/src/main/ipc/worktrees-local-base-ref-resolution.test.ts @@ -16,11 +16,26 @@ vi.mock('electron', async () => vi.mock('../git/worktree', async () => (await import('./worktrees-test-module-mocks')).gitWorktreeModuleMock() ) +vi.mock('../git/worktree-listing', async () => + (await import('./worktrees-test-module-mocks')).gitWorktreeListingModuleMock() +) +vi.mock('../git/worktree-removal-preflight', async () => + (await import('./worktrees-test-module-mocks')).gitWorktreeRemovalPreflightModuleMock() +) +vi.mock('../git/worktree-branch-cleanup', async () => + (await import('./worktrees-test-module-mocks')).gitWorktreeBranchCleanupModuleMock() +) vi.mock('../git/runner', async () => (await import('./worktrees-test-module-mocks')).gitRunnerModuleMock() ) -vi.mock('../git/repo', async () => - (await import('./worktrees-test-module-mocks')).gitRepoModuleMock() +vi.mock('../git/default-base-ref', async () => + (await import('./worktrees-test-module-mocks')).defaultBaseRefModuleMock() +) +vi.mock('../git/repository-remotes', async () => + (await import('./worktrees-test-module-mocks')).repositoryRemotesModuleMock() +) +vi.mock('../git/branch-conflict-detection', async () => + (await import('./worktrees-test-module-mocks')).branchConflictDetectionModuleMock() ) vi.mock('../git/git-username', async (importOriginal) => ({ ...(await importOriginal>()), diff --git a/src/main/ipc/worktrees-local-create-flow.test.ts b/src/main/ipc/worktrees-local-create-flow.test.ts index 570aa38c09b..646258f31b3 100644 --- a/src/main/ipc/worktrees-local-create-flow.test.ts +++ b/src/main/ipc/worktrees-local-create-flow.test.ts @@ -25,11 +25,26 @@ vi.mock('electron', async () => vi.mock('../git/worktree', async () => (await import('./worktrees-test-module-mocks')).gitWorktreeModuleMock() ) +vi.mock('../git/worktree-listing', async () => + (await import('./worktrees-test-module-mocks')).gitWorktreeListingModuleMock() +) +vi.mock('../git/worktree-removal-preflight', async () => + (await import('./worktrees-test-module-mocks')).gitWorktreeRemovalPreflightModuleMock() +) +vi.mock('../git/worktree-branch-cleanup', async () => + (await import('./worktrees-test-module-mocks')).gitWorktreeBranchCleanupModuleMock() +) vi.mock('../git/runner', async () => (await import('./worktrees-test-module-mocks')).gitRunnerModuleMock() ) -vi.mock('../git/repo', async () => - (await import('./worktrees-test-module-mocks')).gitRepoModuleMock() +vi.mock('../git/default-base-ref', async () => + (await import('./worktrees-test-module-mocks')).defaultBaseRefModuleMock() +) +vi.mock('../git/repository-remotes', async () => + (await import('./worktrees-test-module-mocks')).repositoryRemotesModuleMock() +) +vi.mock('../git/branch-conflict-detection', async () => + (await import('./worktrees-test-module-mocks')).branchConflictDetectionModuleMock() ) vi.mock('../git/git-username', async (importOriginal) => ({ ...(await importOriginal>()), diff --git a/src/main/ipc/worktrees-orphan-directory-cleanup.test.ts b/src/main/ipc/worktrees-orphan-directory-cleanup.test.ts index 954a994940d..f7bb52a401d 100644 --- a/src/main/ipc/worktrees-orphan-directory-cleanup.test.ts +++ b/src/main/ipc/worktrees-orphan-directory-cleanup.test.ts @@ -25,8 +25,14 @@ vi.mock('../git/worktree', async () => vi.mock('../git/runner', async () => (await import('./worktrees-test-module-mocks')).gitRunnerModuleMock() ) -vi.mock('../git/repo', async () => - (await import('./worktrees-test-module-mocks')).gitRepoModuleMock() +vi.mock('../git/default-base-ref', async () => + (await import('./worktrees-test-module-mocks')).defaultBaseRefModuleMock() +) +vi.mock('../git/repository-remotes', async () => + (await import('./worktrees-test-module-mocks')).repositoryRemotesModuleMock() +) +vi.mock('../git/branch-conflict-detection', async () => + (await import('./worktrees-test-module-mocks')).branchConflictDetectionModuleMock() ) vi.mock('../git/git-username', async (importOriginal) => ({ ...(await importOriginal>()), diff --git a/src/main/ipc/worktrees-preserved-branch-fork-remote.test.ts b/src/main/ipc/worktrees-preserved-branch-fork-remote.test.ts index bd3501abbe7..913d8da6077 100644 --- a/src/main/ipc/worktrees-preserved-branch-fork-remote.test.ts +++ b/src/main/ipc/worktrees-preserved-branch-fork-remote.test.ts @@ -15,11 +15,26 @@ vi.mock('electron', async () => vi.mock('../git/worktree', async () => (await import('./worktrees-test-module-mocks')).gitWorktreeModuleMock() ) +vi.mock('../git/worktree-listing', async () => + (await import('./worktrees-test-module-mocks')).gitWorktreeListingModuleMock() +) +vi.mock('../git/worktree-removal-preflight', async () => + (await import('./worktrees-test-module-mocks')).gitWorktreeRemovalPreflightModuleMock() +) +vi.mock('../git/worktree-branch-cleanup', async () => + (await import('./worktrees-test-module-mocks')).gitWorktreeBranchCleanupModuleMock() +) vi.mock('../git/runner', async () => (await import('./worktrees-test-module-mocks')).gitRunnerModuleMock() ) -vi.mock('../git/repo', async () => - (await import('./worktrees-test-module-mocks')).gitRepoModuleMock() +vi.mock('../git/default-base-ref', async () => + (await import('./worktrees-test-module-mocks')).defaultBaseRefModuleMock() +) +vi.mock('../git/repository-remotes', async () => + (await import('./worktrees-test-module-mocks')).repositoryRemotesModuleMock() +) +vi.mock('../git/branch-conflict-detection', async () => + (await import('./worktrees-test-module-mocks')).branchConflictDetectionModuleMock() ) vi.mock('../git/git-username', async (importOriginal) => ({ ...(await importOriginal>()), diff --git a/src/main/ipc/worktrees-removal-recovery.test.ts b/src/main/ipc/worktrees-removal-recovery.test.ts index cc10e1929a1..e1ac1fbabbf 100644 --- a/src/main/ipc/worktrees-removal-recovery.test.ts +++ b/src/main/ipc/worktrees-removal-recovery.test.ts @@ -34,11 +34,26 @@ vi.mock('electron', async () => vi.mock('../git/worktree', async () => (await import('./worktrees-test-module-mocks')).gitWorktreeModuleMock() ) +vi.mock('../git/worktree-listing', async () => + (await import('./worktrees-test-module-mocks')).gitWorktreeListingModuleMock() +) +vi.mock('../git/worktree-removal-preflight', async () => + (await import('./worktrees-test-module-mocks')).gitWorktreeRemovalPreflightModuleMock() +) +vi.mock('../git/worktree-branch-cleanup', async () => + (await import('./worktrees-test-module-mocks')).gitWorktreeBranchCleanupModuleMock() +) vi.mock('../git/runner', async () => (await import('./worktrees-test-module-mocks')).gitRunnerModuleMock() ) -vi.mock('../git/repo', async () => - (await import('./worktrees-test-module-mocks')).gitRepoModuleMock() +vi.mock('../git/default-base-ref', async () => + (await import('./worktrees-test-module-mocks')).defaultBaseRefModuleMock() +) +vi.mock('../git/repository-remotes', async () => + (await import('./worktrees-test-module-mocks')).repositoryRemotesModuleMock() +) +vi.mock('../git/branch-conflict-detection', async () => + (await import('./worktrees-test-module-mocks')).branchConflictDetectionModuleMock() ) vi.mock('../git/git-username', async (importOriginal) => ({ ...(await importOriginal>()), diff --git a/src/main/ipc/worktrees-remove-archive-hooks.test.ts b/src/main/ipc/worktrees-remove-archive-hooks.test.ts index cb1b39c8d6e..47b2abdc308 100644 --- a/src/main/ipc/worktrees-remove-archive-hooks.test.ts +++ b/src/main/ipc/worktrees-remove-archive-hooks.test.ts @@ -22,11 +22,26 @@ vi.mock('electron', async () => vi.mock('../git/worktree', async () => (await import('./worktrees-test-module-mocks')).gitWorktreeModuleMock() ) +vi.mock('../git/worktree-listing', async () => + (await import('./worktrees-test-module-mocks')).gitWorktreeListingModuleMock() +) +vi.mock('../git/worktree-removal-preflight', async () => + (await import('./worktrees-test-module-mocks')).gitWorktreeRemovalPreflightModuleMock() +) +vi.mock('../git/worktree-branch-cleanup', async () => + (await import('./worktrees-test-module-mocks')).gitWorktreeBranchCleanupModuleMock() +) vi.mock('../git/runner', async () => (await import('./worktrees-test-module-mocks')).gitRunnerModuleMock() ) -vi.mock('../git/repo', async () => - (await import('./worktrees-test-module-mocks')).gitRepoModuleMock() +vi.mock('../git/default-base-ref', async () => + (await import('./worktrees-test-module-mocks')).defaultBaseRefModuleMock() +) +vi.mock('../git/repository-remotes', async () => + (await import('./worktrees-test-module-mocks')).repositoryRemotesModuleMock() +) +vi.mock('../git/branch-conflict-detection', async () => + (await import('./worktrees-test-module-mocks')).branchConflictDetectionModuleMock() ) vi.mock('../git/git-username', async (importOriginal) => ({ ...(await importOriginal>()), diff --git a/src/main/ipc/worktrees-remove-host-disambiguation.test.ts b/src/main/ipc/worktrees-remove-host-disambiguation.test.ts index 289609a2b86..9148cdda263 100644 --- a/src/main/ipc/worktrees-remove-host-disambiguation.test.ts +++ b/src/main/ipc/worktrees-remove-host-disambiguation.test.ts @@ -18,8 +18,14 @@ vi.mock('../git/worktree', async () => vi.mock('../git/runner', async () => (await import('./worktrees-test-module-mocks')).gitRunnerModuleMock() ) -vi.mock('../git/repo', async () => - (await import('./worktrees-test-module-mocks')).gitRepoModuleMock() +vi.mock('../git/default-base-ref', async () => + (await import('./worktrees-test-module-mocks')).defaultBaseRefModuleMock() +) +vi.mock('../git/repository-remotes', async () => + (await import('./worktrees-test-module-mocks')).repositoryRemotesModuleMock() +) +vi.mock('../git/branch-conflict-detection', async () => + (await import('./worktrees-test-module-mocks')).branchConflictDetectionModuleMock() ) vi.mock('../git/git-username', async (importOriginal) => ({ ...(await importOriginal>()), diff --git a/src/main/ipc/worktrees-remove-preflight.test.ts b/src/main/ipc/worktrees-remove-preflight.test.ts index 335eeda6e90..efd98dbb27c 100644 --- a/src/main/ipc/worktrees-remove-preflight.test.ts +++ b/src/main/ipc/worktrees-remove-preflight.test.ts @@ -23,11 +23,26 @@ vi.mock('electron', async () => vi.mock('../git/worktree', async () => (await import('./worktrees-test-module-mocks')).gitWorktreeModuleMock() ) +vi.mock('../git/worktree-listing', async () => + (await import('./worktrees-test-module-mocks')).gitWorktreeListingModuleMock() +) +vi.mock('../git/worktree-removal-preflight', async () => + (await import('./worktrees-test-module-mocks')).gitWorktreeRemovalPreflightModuleMock() +) +vi.mock('../git/worktree-branch-cleanup', async () => + (await import('./worktrees-test-module-mocks')).gitWorktreeBranchCleanupModuleMock() +) vi.mock('../git/runner', async () => (await import('./worktrees-test-module-mocks')).gitRunnerModuleMock() ) -vi.mock('../git/repo', async () => - (await import('./worktrees-test-module-mocks')).gitRepoModuleMock() +vi.mock('../git/default-base-ref', async () => + (await import('./worktrees-test-module-mocks')).defaultBaseRefModuleMock() +) +vi.mock('../git/repository-remotes', async () => + (await import('./worktrees-test-module-mocks')).repositoryRemotesModuleMock() +) +vi.mock('../git/branch-conflict-detection', async () => + (await import('./worktrees-test-module-mocks')).branchConflictDetectionModuleMock() ) vi.mock('../git/git-username', async (importOriginal) => ({ ...(await importOriginal>()), diff --git a/src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts b/src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts index 6eb250af69a..a0568a6b1d9 100644 --- a/src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts +++ b/src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts @@ -17,11 +17,26 @@ vi.mock('electron', async () => vi.mock('../git/worktree', async () => (await import('./worktrees-test-module-mocks')).gitWorktreeModuleMock() ) +vi.mock('../git/worktree-listing', async () => + (await import('./worktrees-test-module-mocks')).gitWorktreeListingModuleMock() +) +vi.mock('../git/worktree-removal-preflight', async () => + (await import('./worktrees-test-module-mocks')).gitWorktreeRemovalPreflightModuleMock() +) +vi.mock('../git/worktree-branch-cleanup', async () => + (await import('./worktrees-test-module-mocks')).gitWorktreeBranchCleanupModuleMock() +) vi.mock('../git/runner', async () => (await import('./worktrees-test-module-mocks')).gitRunnerModuleMock() ) -vi.mock('../git/repo', async () => - (await import('./worktrees-test-module-mocks')).gitRepoModuleMock() +vi.mock('../git/default-base-ref', async () => + (await import('./worktrees-test-module-mocks')).defaultBaseRefModuleMock() +) +vi.mock('../git/repository-remotes', async () => + (await import('./worktrees-test-module-mocks')).repositoryRemotesModuleMock() +) +vi.mock('../git/branch-conflict-detection', async () => + (await import('./worktrees-test-module-mocks')).branchConflictDetectionModuleMock() ) vi.mock('../git/git-username', async (importOriginal) => ({ ...(await importOriginal>()), diff --git a/src/main/ipc/worktrees-ssh-base-ref-resolution.test.ts b/src/main/ipc/worktrees-ssh-base-ref-resolution.test.ts index 599acb1bcc7..71cda52c5e8 100644 --- a/src/main/ipc/worktrees-ssh-base-ref-resolution.test.ts +++ b/src/main/ipc/worktrees-ssh-base-ref-resolution.test.ts @@ -16,8 +16,14 @@ vi.mock('../git/worktree', async () => vi.mock('../git/runner', async () => (await import('./worktrees-test-module-mocks')).gitRunnerModuleMock() ) -vi.mock('../git/repo', async () => - (await import('./worktrees-test-module-mocks')).gitRepoModuleMock() +vi.mock('../git/default-base-ref', async () => + (await import('./worktrees-test-module-mocks')).defaultBaseRefModuleMock() +) +vi.mock('../git/repository-remotes', async () => + (await import('./worktrees-test-module-mocks')).repositoryRemotesModuleMock() +) +vi.mock('../git/branch-conflict-detection', async () => + (await import('./worktrees-test-module-mocks')).branchConflictDetectionModuleMock() ) vi.mock('../git/git-username', async (importOriginal) => ({ ...(await importOriginal>()), diff --git a/src/main/ipc/worktrees-ssh-branch-conflict-suffixing.test.ts b/src/main/ipc/worktrees-ssh-branch-conflict-suffixing.test.ts index 73605e2332e..1111d251131 100644 --- a/src/main/ipc/worktrees-ssh-branch-conflict-suffixing.test.ts +++ b/src/main/ipc/worktrees-ssh-branch-conflict-suffixing.test.ts @@ -15,8 +15,14 @@ vi.mock('../git/worktree', async () => vi.mock('../git/runner', async () => (await import('./worktrees-test-module-mocks')).gitRunnerModuleMock() ) -vi.mock('../git/repo', async () => - (await import('./worktrees-test-module-mocks')).gitRepoModuleMock() +vi.mock('../git/default-base-ref', async () => + (await import('./worktrees-test-module-mocks')).defaultBaseRefModuleMock() +) +vi.mock('../git/repository-remotes', async () => + (await import('./worktrees-test-module-mocks')).repositoryRemotesModuleMock() +) +vi.mock('../git/branch-conflict-detection', async () => + (await import('./worktrees-test-module-mocks')).branchConflictDetectionModuleMock() ) vi.mock('../git/git-username', async (importOriginal) => ({ ...(await importOriginal>()), diff --git a/src/main/ipc/worktrees-ssh-create-base-prefetch.test.ts b/src/main/ipc/worktrees-ssh-create-base-prefetch.test.ts index a14d7572f03..35d2abacaf6 100644 --- a/src/main/ipc/worktrees-ssh-create-base-prefetch.test.ts +++ b/src/main/ipc/worktrees-ssh-create-base-prefetch.test.ts @@ -11,8 +11,14 @@ vi.mock('../git/worktree', async () => vi.mock('../git/runner', async () => (await import('./worktrees-test-module-mocks')).gitRunnerModuleMock() ) -vi.mock('../git/repo', async () => - (await import('./worktrees-test-module-mocks')).gitRepoModuleMock() +vi.mock('../git/default-base-ref', async () => + (await import('./worktrees-test-module-mocks')).defaultBaseRefModuleMock() +) +vi.mock('../git/repository-remotes', async () => + (await import('./worktrees-test-module-mocks')).repositoryRemotesModuleMock() +) +vi.mock('../git/branch-conflict-detection', async () => + (await import('./worktrees-test-module-mocks')).branchConflictDetectionModuleMock() ) vi.mock('../git/git-username', async (importOriginal) => ({ ...(await importOriginal>()), diff --git a/src/main/ipc/worktrees-ssh-fork-push-target-remote.test.ts b/src/main/ipc/worktrees-ssh-fork-push-target-remote.test.ts index 632aba29d0c..ddd0960c5da 100644 --- a/src/main/ipc/worktrees-ssh-fork-push-target-remote.test.ts +++ b/src/main/ipc/worktrees-ssh-fork-push-target-remote.test.ts @@ -12,8 +12,14 @@ vi.mock('../git/worktree', async () => vi.mock('../git/runner', async () => (await import('./worktrees-test-module-mocks')).gitRunnerModuleMock() ) -vi.mock('../git/repo', async () => - (await import('./worktrees-test-module-mocks')).gitRepoModuleMock() +vi.mock('../git/default-base-ref', async () => + (await import('./worktrees-test-module-mocks')).defaultBaseRefModuleMock() +) +vi.mock('../git/repository-remotes', async () => + (await import('./worktrees-test-module-mocks')).repositoryRemotesModuleMock() +) +vi.mock('../git/branch-conflict-detection', async () => + (await import('./worktrees-test-module-mocks')).branchConflictDetectionModuleMock() ) vi.mock('../git/git-username', async (importOriginal) => ({ ...(await importOriginal>()), diff --git a/src/main/ipc/worktrees-ssh-local-base-refresh.test.ts b/src/main/ipc/worktrees-ssh-local-base-refresh.test.ts index 0b76025a979..c8d33f89f3b 100644 --- a/src/main/ipc/worktrees-ssh-local-base-refresh.test.ts +++ b/src/main/ipc/worktrees-ssh-local-base-refresh.test.ts @@ -12,8 +12,14 @@ vi.mock('../git/worktree', async () => vi.mock('../git/runner', async () => (await import('./worktrees-test-module-mocks')).gitRunnerModuleMock() ) -vi.mock('../git/repo', async () => - (await import('./worktrees-test-module-mocks')).gitRepoModuleMock() +vi.mock('../git/default-base-ref', async () => + (await import('./worktrees-test-module-mocks')).defaultBaseRefModuleMock() +) +vi.mock('../git/repository-remotes', async () => + (await import('./worktrees-test-module-mocks')).repositoryRemotesModuleMock() +) +vi.mock('../git/branch-conflict-detection', async () => + (await import('./worktrees-test-module-mocks')).branchConflictDetectionModuleMock() ) vi.mock('../git/git-username', async (importOriginal) => ({ ...(await importOriginal>()), diff --git a/src/main/ipc/worktrees-ssh-pr-head-fetch.test.ts b/src/main/ipc/worktrees-ssh-pr-head-fetch.test.ts index 109dca653d3..a6ccda57c34 100644 --- a/src/main/ipc/worktrees-ssh-pr-head-fetch.test.ts +++ b/src/main/ipc/worktrees-ssh-pr-head-fetch.test.ts @@ -19,8 +19,14 @@ vi.mock('../git/worktree', async () => vi.mock('../git/runner', async () => (await import('./worktrees-test-module-mocks')).gitRunnerModuleMock() ) -vi.mock('../git/repo', async () => - (await import('./worktrees-test-module-mocks')).gitRepoModuleMock() +vi.mock('../git/default-base-ref', async () => + (await import('./worktrees-test-module-mocks')).defaultBaseRefModuleMock() +) +vi.mock('../git/repository-remotes', async () => + (await import('./worktrees-test-module-mocks')).repositoryRemotesModuleMock() +) +vi.mock('../git/branch-conflict-detection', async () => + (await import('./worktrees-test-module-mocks')).branchConflictDetectionModuleMock() ) vi.mock('../git/git-username', async (importOriginal) => ({ ...(await importOriginal>()), diff --git a/src/main/ipc/worktrees-ssh-provider-authority.test.ts b/src/main/ipc/worktrees-ssh-provider-authority.test.ts index c801d0498f3..372f93f1c27 100644 --- a/src/main/ipc/worktrees-ssh-provider-authority.test.ts +++ b/src/main/ipc/worktrees-ssh-provider-authority.test.ts @@ -17,8 +17,14 @@ vi.mock('../git/worktree', async () => vi.mock('../git/runner', async () => (await import('./worktrees-test-module-mocks')).gitRunnerModuleMock() ) -vi.mock('../git/repo', async () => - (await import('./worktrees-test-module-mocks')).gitRepoModuleMock() +vi.mock('../git/default-base-ref', async () => + (await import('./worktrees-test-module-mocks')).defaultBaseRefModuleMock() +) +vi.mock('../git/repository-remotes', async () => + (await import('./worktrees-test-module-mocks')).repositoryRemotesModuleMock() +) +vi.mock('../git/branch-conflict-detection', async () => + (await import('./worktrees-test-module-mocks')).branchConflictDetectionModuleMock() ) vi.mock('../git/git-username', async (importOriginal) => ({ ...(await importOriginal>()), diff --git a/src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts b/src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts index 6a398e9b02d..379e3826116 100644 --- a/src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts +++ b/src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts @@ -21,8 +21,14 @@ vi.mock('../git/worktree', async () => vi.mock('../git/runner', async () => (await import('./worktrees-test-module-mocks')).gitRunnerModuleMock() ) -vi.mock('../git/repo', async () => - (await import('./worktrees-test-module-mocks')).gitRepoModuleMock() +vi.mock('../git/default-base-ref', async () => + (await import('./worktrees-test-module-mocks')).defaultBaseRefModuleMock() +) +vi.mock('../git/repository-remotes', async () => + (await import('./worktrees-test-module-mocks')).repositoryRemotesModuleMock() +) +vi.mock('../git/branch-conflict-detection', async () => + (await import('./worktrees-test-module-mocks')).branchConflictDetectionModuleMock() ) vi.mock('../git/git-username', async (importOriginal) => ({ ...(await importOriginal>()), diff --git a/src/main/ipc/worktrees-ssh-setup-launch.test.ts b/src/main/ipc/worktrees-ssh-setup-launch.test.ts index c43d4d7cb7c..3334cafc387 100644 --- a/src/main/ipc/worktrees-ssh-setup-launch.test.ts +++ b/src/main/ipc/worktrees-ssh-setup-launch.test.ts @@ -19,8 +19,14 @@ vi.mock('../git/worktree', async () => vi.mock('../git/runner', async () => (await import('./worktrees-test-module-mocks')).gitRunnerModuleMock() ) -vi.mock('../git/repo', async () => - (await import('./worktrees-test-module-mocks')).gitRepoModuleMock() +vi.mock('../git/default-base-ref', async () => + (await import('./worktrees-test-module-mocks')).defaultBaseRefModuleMock() +) +vi.mock('../git/repository-remotes', async () => + (await import('./worktrees-test-module-mocks')).repositoryRemotesModuleMock() +) +vi.mock('../git/branch-conflict-detection', async () => + (await import('./worktrees-test-module-mocks')).branchConflictDetectionModuleMock() ) vi.mock('../git/git-username', async (importOriginal) => ({ ...(await importOriginal>()), diff --git a/src/main/ipc/worktrees-test-module-mocks.ts b/src/main/ipc/worktrees-test-module-mocks.ts index 50bdc6a21d7..80f9d053a30 100644 --- a/src/main/ipc/worktrees-test-module-mocks.ts +++ b/src/main/ipc/worktrees-test-module-mocks.ts @@ -111,13 +111,21 @@ export const electronModuleMock = () => ({ }) export const gitWorktreeModuleMock = () => ({ - listWorktrees: listWorktreesMock, - listWorktreesStrict: listWorktreesMock, - parseWorktreeList: parseWorktreeListMock, - assertWorktreeCleanForRemoval: assertWorktreeCleanForRemovalMock, addWorktree: addWorktreeMock, addSparseWorktree: addSparseWorktreeMock, - removeWorktree: removeWorktreeMock, + removeWorktree: removeWorktreeMock +}) + +export const gitWorktreeListingModuleMock = () => ({ + listWorktrees: listWorktreesMock, + listWorktreesStrict: listWorktreesMock +}) + +export const gitWorktreeRemovalPreflightModuleMock = () => ({ + assertWorktreeCleanForRemoval: assertWorktreeCleanForRemovalMock +}) + +export const gitWorktreeBranchCleanupModuleMock = () => ({ forceDeleteLocalBranch: forceDeleteLocalBranchMock }) @@ -129,11 +137,17 @@ export const gitRunnerModuleMock = (): { gitExecFileSync: vi.fn() }) -export const gitRepoModuleMock = () => ({ +export const defaultBaseRefModuleMock = () => ({ getBaseRefDefault: getBaseRefDefaultMock, resolveDefaultBaseRefWithLocalGit: resolveDefaultBaseRefWithLocalGitMock, - resolveDefaultBaseRefViaExec: resolveDefaultBaseRefViaExecMock, - getDefaultRemote: getDefaultRemoteMock, + resolveDefaultBaseRefViaExec: resolveDefaultBaseRefViaExecMock +}) + +export const repositoryRemotesModuleMock = () => ({ + getDefaultRemote: getDefaultRemoteMock +}) + +export const branchConflictDetectionModuleMock = () => ({ getBranchConflictKind: getBranchConflictKindMock }) diff --git a/src/main/ipc/worktrees-windows.test.ts b/src/main/ipc/worktrees-windows.test.ts index d4b3dde71dc..4aefa248c7c 100644 --- a/src/main/ipc/worktrees-windows.test.ts +++ b/src/main/ipc/worktrees-windows.test.ts @@ -71,12 +71,16 @@ vi.mock('electron', () => ({ })) vi.mock('../git/worktree', () => ({ - listWorktrees: listWorktreesMock, - listWorktreesStrict: listWorktreesMock, - assertWorktreeCleanForRemoval: assertWorktreeCleanForRemovalMock, addWorktree: addWorktreeMock, removeWorktree: removeWorktreeMock })) +vi.mock('../git/worktree-listing', () => ({ + listWorktrees: listWorktreesMock, + listWorktreesStrict: listWorktreesMock +})) +vi.mock('../git/worktree-removal-preflight', () => ({ + assertWorktreeCleanForRemoval: assertWorktreeCleanForRemovalMock +})) vi.mock('../git/runner', () => ({ // Why: createLocalWorktree now fires `git fetch` via gitExecFileAsync in the @@ -85,11 +89,15 @@ vi.mock('../git/runner', () => ({ gitExecFileAsync: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }), gitExecFileSync: vi.fn() })) +vi.mock('../git/git-process-launch', () => ({ gitExecFileSync: vi.fn() })) -vi.mock('../git/repo', () => ({ +vi.mock('../git/default-base-ref', () => ({ getDefaultBaseRef: getDefaultBaseRefMock, resolveDefaultBaseRefWithLocalGit: resolveDefaultBaseRefWithLocalGitMock, - resolveDefaultBaseRefViaExec: resolveDefaultBaseRefViaExecMock, + resolveDefaultBaseRefViaExec: resolveDefaultBaseRefViaExecMock +})) + +vi.mock('../git/branch-conflict-detection', () => ({ getBranchConflictKind: getBranchConflictKindMock })) diff --git a/src/main/ipc/worktrees-wsl-runtime-routing.test.ts b/src/main/ipc/worktrees-wsl-runtime-routing.test.ts index 23758ae5d77..7e6520f8970 100644 --- a/src/main/ipc/worktrees-wsl-runtime-routing.test.ts +++ b/src/main/ipc/worktrees-wsl-runtime-routing.test.ts @@ -28,11 +28,26 @@ vi.mock('electron', async () => vi.mock('../git/worktree', async () => (await import('./worktrees-test-module-mocks')).gitWorktreeModuleMock() ) +vi.mock('../git/worktree-listing', async () => + (await import('./worktrees-test-module-mocks')).gitWorktreeListingModuleMock() +) +vi.mock('../git/worktree-removal-preflight', async () => + (await import('./worktrees-test-module-mocks')).gitWorktreeRemovalPreflightModuleMock() +) +vi.mock('../git/worktree-branch-cleanup', async () => + (await import('./worktrees-test-module-mocks')).gitWorktreeBranchCleanupModuleMock() +) vi.mock('../git/runner', async () => (await import('./worktrees-test-module-mocks')).gitRunnerModuleMock() ) -vi.mock('../git/repo', async () => - (await import('./worktrees-test-module-mocks')).gitRepoModuleMock() +vi.mock('../git/default-base-ref', async () => + (await import('./worktrees-test-module-mocks')).defaultBaseRefModuleMock() +) +vi.mock('../git/repository-remotes', async () => + (await import('./worktrees-test-module-mocks')).repositoryRemotesModuleMock() +) +vi.mock('../git/branch-conflict-detection', async () => + (await import('./worktrees-test-module-mocks')).branchConflictDetectionModuleMock() ) vi.mock('../git/git-username', async (importOriginal) => ({ ...(await importOriginal>()), diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 4b4d009024f..a90eb26c11b 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -82,12 +82,10 @@ import { resolveCustomWorktreeVisibilitySources } from '../../shared/worktree/visibility-sources' import { resolveConfiguredWorktreeBasePaths } from '../../shared/worktree/configured-worktree-base-path' -import { - assertWorktreeCleanForRemoval, - forceDeleteLocalBranch, - listWorktreesStrict as listGitWorktreesStrict, - removeWorktree -} from '../git/worktree' +import { removeWorktree } from '../git/worktree' +import { forceDeleteLocalBranch } from '../git/worktree-branch-cleanup' +import { listWorktreesStrict as listGitWorktreesStrict } from '../git/worktree-listing' +import { assertWorktreeCleanForRemoval } from '../git/worktree-removal-preflight' import { gitExecFileAsync } from '../git/runner' import { withWorktreeRemoveStageSpan, withWorktreeSpan } from '../observability/instrumentation' import { resolveGitHubPrStartPoint } from '../github/pr-start-point' @@ -95,7 +93,7 @@ import { fetchGitHubPullRequestHeadRef, fetchPrHeadTrackingRef } from '../github/pr-head-tracking-ref' -import { pruneWorktreePRRefreshAliases } from '../github/pr-refresh-coordinator' +import { pruneWorktreeRefreshAliases } from '../github/pr-refresh-queue-state' import { resolveGitHubReviewHeadRemote } from '../github/review-head-remote' import { listRepoWorktrees } from '../repo-worktrees' import { getSshGitProvider, requireSshGitProvider } from '../providers/ssh-git-dispatch' @@ -355,7 +353,7 @@ function removeWorktreeMetadataAndTransientState( // Why: schedule async history tree removal — never recursive-rmSync on the delete critical path. deleteWorktreeHistoryDir(worktreeId) // Why: release the removed worktree's PR-refresh aliases so coalesced queue entries don't retain it all session (memory creep). - pruneWorktreePRRefreshAliases(worktreeId) + pruneWorktreeRefreshAliases(worktreeId) } // Why: removed workspaces must never resurrect from the persisted cleanup/space scan snapshots. const snapshotDirectory = store.getProfileStorageDirectory() diff --git a/src/main/local-worktree-removal-recovery.test.ts b/src/main/local-worktree-removal-recovery.test.ts index 1be0fd02cce..a2395cd7e5b 100644 --- a/src/main/local-worktree-removal-recovery.test.ts +++ b/src/main/local-worktree-removal-recovery.test.ts @@ -16,7 +16,7 @@ vi.mock('./local-worktree-filesystem', () => ({ removeLocalWorktreePath: removeLocalWorktreePathMock })) -vi.mock('./git/worktree', () => ({ +vi.mock('./git/worktree-listing', () => ({ listWorktreesStrict: listWorktreesStrictMock })) diff --git a/src/main/local-worktree-removal-recovery.ts b/src/main/local-worktree-removal-recovery.ts index 630dd338c20..70d2727f2b2 100644 --- a/src/main/local-worktree-removal-recovery.ts +++ b/src/main/local-worktree-removal-recovery.ts @@ -3,7 +3,8 @@ import type { GitWorktreeInfo } from '../shared/worktree/types' import { assertWorktreeUnlockedForRemoval } from '../shared/worktree/removal' import { areWorktreePathsEqual, formatWorktreeRemovalError } from './ipc/worktree-logic' import { gitExecFileAsync } from './git/runner' -import { listWorktreesStrict, type GitWorktreeExecOptions } from './git/worktree' +import type { GitWorktreeExecOptions } from './git/worktree-execution-options' +import { listWorktreesStrict } from './git/worktree-listing' import { removeLocalWorktreePath } from './local-worktree-filesystem' type LocalWindowsRemovalRecoveryArgs = { diff --git a/src/main/project-groups/nested-repo-import-target.test.ts b/src/main/project-groups/nested-repo-import-target.test.ts index c630e102fb3..b6906d74e50 100644 --- a/src/main/project-groups/nested-repo-import-target.test.ts +++ b/src/main/project-groups/nested-repo-import-target.test.ts @@ -1,14 +1,14 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' import { join } from 'node:path' import type { GitWorktreeInfo } from '../../shared/worktree/types' -import { listWorktreeGraph } from '../git/worktree' +import { listWorktreeGraph } from '../git/worktree-listing' import { createNestedRepoImportTargetResolver, resolveLocalNestedRepoImportTargetPath, resolveSshNestedRepoImportTargetPath } from './nested-repo-import-target' -vi.mock('../git/worktree', () => ({ +vi.mock('../git/worktree-listing', () => ({ listWorktreeGraph: vi.fn() })) diff --git a/src/main/project-groups/nested-repo-import-target.ts b/src/main/project-groups/nested-repo-import-target.ts index be722716856..e412daa85a6 100644 --- a/src/main/project-groups/nested-repo-import-target.ts +++ b/src/main/project-groups/nested-repo-import-target.ts @@ -1,6 +1,6 @@ import type { GitWorktreeInfo } from '../../shared/worktree/types' import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' -import { listWorktreeGraph } from '../git/worktree' +import { listWorktreeGraph } from '../git/worktree-listing' type WorktreeLister = { listWorktrees: (repoPath: string) => Promise diff --git a/src/main/repo-worktrees.test.ts b/src/main/repo-worktrees.test.ts index 3c6910991f0..74e34bcd691 100644 --- a/src/main/repo-worktrees.test.ts +++ b/src/main/repo-worktrees.test.ts @@ -4,7 +4,7 @@ const { listWorktreesMock } = vi.hoisted(() => ({ listWorktreesMock: vi.fn() })) -vi.mock('./git/worktree', () => ({ +vi.mock('./git/worktree-listing', () => ({ listWorktrees: listWorktreesMock })) diff --git a/src/main/repo-worktrees.ts b/src/main/repo-worktrees.ts index f4123c18889..fd9edae8c85 100644 --- a/src/main/repo-worktrees.ts +++ b/src/main/repo-worktrees.ts @@ -1,6 +1,6 @@ import type { Repo } from '../shared/repo-types' import type { GitWorktreeInfo } from '../shared/worktree/types' -import { listWorktrees } from './git/worktree' +import { listWorktrees } from './git/worktree-listing' import { isFolderRepo } from '../shared/repo-kind' import { getSshGitProvider } from './providers/ssh-git-dispatch' import { areWorktreePathsEqual } from './ipc/worktree-logic' diff --git a/src/main/runtime/agent-prompt-submission-runtime.test.ts b/src/main/runtime/agent-prompt-submission-runtime.test.ts index 711e887b5c5..8eb6c5b4799 100644 --- a/src/main/runtime/agent-prompt-submission-runtime.test.ts +++ b/src/main/runtime/agent-prompt-submission-runtime.test.ts @@ -8,7 +8,7 @@ import { makeStore } from './runtime-rpc-worktree-store-fixtures' const WORKTREE_PATH = '/tmp/worktree-a' -vi.mock('../git/worktree', () => ({ +vi.mock('../git/worktree-listing', () => ({ listWorktrees: vi.fn().mockResolvedValue([ { path: '/tmp/worktree-a', diff --git a/src/main/runtime/agent-prompt-submission-windows-submit-delay.test.ts b/src/main/runtime/agent-prompt-submission-windows-submit-delay.test.ts index fdb90700de1..6c09ab5f339 100644 --- a/src/main/runtime/agent-prompt-submission-windows-submit-delay.test.ts +++ b/src/main/runtime/agent-prompt-submission-windows-submit-delay.test.ts @@ -14,7 +14,7 @@ vi.mock('../../shared/agent-prompt-injection', async (importOriginal) => ({ AGENT_PROMPT_SUBMIT_DELAY_MS: WINDOWS_SUBMIT_DELAY_MS })) -vi.mock('../git/worktree', () => ({ +vi.mock('../git/worktree-listing', () => ({ listWorktrees: vi.fn().mockResolvedValue([ { path: '/tmp/worktree-a', diff --git a/src/main/runtime/fit-override-integration.test.ts b/src/main/runtime/fit-override-integration.test.ts index e051ca7ae69..f0e4498a362 100644 --- a/src/main/runtime/fit-override-integration.test.ts +++ b/src/main/runtime/fit-override-integration.test.ts @@ -34,14 +34,14 @@ vi.mock('../ipc/registered-worktree-roots-cache', () => ({ invalidateAuthorizedRootsCache: vi.fn() })) -vi.mock('../git/repo', async (importOriginal) => { - const actual = (await importOriginal()) as Record - return { - ...actual, - getDefaultBaseRef: vi.fn().mockReturnValue('origin/main'), - getBranchConflictKind: vi.fn().mockResolvedValue(null) - } -}) +vi.mock('../git/default-base-ref', async (importOriginal) => ({ + ...((await importOriginal()) as Record), + getDefaultBaseRef: vi.fn().mockReturnValue('origin/main') +})) +vi.mock('../git/branch-conflict-detection', async (importOriginal) => ({ + ...((await importOriginal()) as Record), + getBranchConflictKind: vi.fn().mockResolvedValue(null) +})) vi.mock('../git/git-username', async () => { const actual = await vi.importActual('../git/git-username') diff --git a/src/main/runtime/mobile-presence-lock.test.ts b/src/main/runtime/mobile-presence-lock.test.ts index 7cf24f40534..495e01e2d12 100644 --- a/src/main/runtime/mobile-presence-lock.test.ts +++ b/src/main/runtime/mobile-presence-lock.test.ts @@ -21,14 +21,14 @@ vi.mock('../ipc/worktree-logic', async (importOriginal) => { vi.mock('../ipc/registered-worktree-roots-cache', () => ({ invalidateAuthorizedRootsCache: vi.fn() })) -vi.mock('../git/repo', async (importOriginal) => { - const actual = (await importOriginal()) as Record - return { - ...actual, - getDefaultBaseRef: vi.fn().mockReturnValue('origin/main'), - getBranchConflictKind: vi.fn().mockResolvedValue(null) - } -}) +vi.mock('../git/default-base-ref', async (importOriginal) => ({ + ...((await importOriginal()) as Record), + getDefaultBaseRef: vi.fn().mockReturnValue('origin/main') +})) +vi.mock('../git/branch-conflict-detection', async (importOriginal) => ({ + ...((await importOriginal()) as Record), + getBranchConflictKind: vi.fn().mockResolvedValue(null) +})) vi.mock('../git/git-username', async () => { const actual = await vi.importActual('../git/git-username') diff --git a/src/main/runtime/mobile-subscribe-integration.test.ts b/src/main/runtime/mobile-subscribe-integration.test.ts index cee4fcfe79f..9ce0f58b80c 100644 --- a/src/main/runtime/mobile-subscribe-integration.test.ts +++ b/src/main/runtime/mobile-subscribe-integration.test.ts @@ -34,14 +34,14 @@ vi.mock('../ipc/registered-worktree-roots-cache', () => ({ invalidateAuthorizedRootsCache: vi.fn() })) -vi.mock('../git/repo', async (importOriginal) => { - const actual = (await importOriginal()) as Record - return { - ...actual, - getDefaultBaseRef: vi.fn().mockReturnValue('origin/main'), - getBranchConflictKind: vi.fn().mockResolvedValue(null) - } -}) +vi.mock('../git/default-base-ref', async (importOriginal) => ({ + ...((await importOriginal()) as Record), + getDefaultBaseRef: vi.fn().mockReturnValue('origin/main') +})) +vi.mock('../git/branch-conflict-detection', async (importOriginal) => ({ + ...((await importOriginal()) as Record), + getBranchConflictKind: vi.fn().mockResolvedValue(null) +})) vi.mock('../git/git-username', async () => { const actual = await vi.importActual('../git/git-username') diff --git a/src/main/runtime/multi-client-navigation-isolation.integration.test.ts b/src/main/runtime/multi-client-navigation-isolation.integration.test.ts index a8bcfa8d5ae..333425dc322 100644 --- a/src/main/runtime/multi-client-navigation-isolation.integration.test.ts +++ b/src/main/runtime/multi-client-navigation-isolation.integration.test.ts @@ -28,7 +28,7 @@ import { type ResponseReader } from './paired-client-navigation-test-harness' -vi.mock('../git/worktree', () => { +vi.mock('../git/worktree-listing', () => { const worktrees = ['host', 'client-a', 'client-a2', 'client-b', 'session'].map((name) => ({ path: `/tmp/${name}`, head: name, diff --git a/src/main/runtime/orca-runtime-git-diff-budget.test.ts b/src/main/runtime/orca-runtime-git-diff-budget.test.ts index 2e30f7f931e..1fe392f3f41 100644 --- a/src/main/runtime/orca-runtime-git-diff-budget.test.ts +++ b/src/main/runtime/orca-runtime-git-diff-budget.test.ts @@ -4,7 +4,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { REMOTE_RPC_MAX_CONTENT_BYTES } from '../../shared/remote-rpc-content-budget' import type { GitDiffResult } from '../../shared/git-diff-compare-types' import type { GlobalSettings } from '../../shared/global-settings-types' -import type * as GitStatusModule from '../git/status' +import type * as GitFileDiffModule from '../git/status-file-diff' +import type * as GitBranchComparisonModule from '../git/branch-comparison' +import type * as GitCommitComparisonModule from '../git/commit-comparison' import { RuntimeGitCommands, type ResolvedRuntimeGitWorktree } from './orca-runtime-git' const mocks = vi.hoisted(() => ({ @@ -18,10 +20,18 @@ vi.mock('../providers/ssh-git-dispatch', () => ({ getSshGitProvider: mocks.getSshGitProvider })) -vi.mock('../git/status', async () => ({ - ...(await vi.importActual('../git/status')), - getDiff: mocks.getDiff, - getBranchDiff: mocks.getBranchDiff, +vi.mock('../git/status-file-diff', async () => ({ + ...(await vi.importActual('../git/status-file-diff')), + getDiff: mocks.getDiff +})) + +vi.mock('../git/branch-comparison', async () => ({ + ...(await vi.importActual('../git/branch-comparison')), + getBranchDiff: mocks.getBranchDiff +})) + +vi.mock('../git/commit-comparison', async () => ({ + ...(await vi.importActual('../git/commit-comparison')), getCommitDiff: mocks.getCommitDiff })) diff --git a/src/main/runtime/orca-runtime-git.test.ts b/src/main/runtime/orca-runtime-git.test.ts index 26b993704f2..3230556ff42 100644 --- a/src/main/runtime/orca-runtime-git.test.ts +++ b/src/main/runtime/orca-runtime-git.test.ts @@ -4,6 +4,8 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { GlobalSettings } from '../../shared/global-settings-types' import type * as GitStatusModule from '../git/status' +import type * as GitConflictStatusModule from '../git/conflict-status' +import type * as GitCommitOperationsModule from '../git/git-commit-operations' import type * as CommitMessageTextGenerationModule from '../text-generation/commit-message-text-generation' import type * as PullRequestContextModule from '../text-generation/pull-request-context' import { RuntimeGitCommands, type ResolvedRuntimeGitWorktree } from './orca-runtime-git' @@ -26,12 +28,20 @@ const mocks = vi.hoisted(() => ({ vi.mock('../git/status', async () => ({ ...(await vi.importActual('../git/status')), - abortMerge: mocks.abortMerge, - abortRebase: mocks.abortRebase, - getStagedCommitContext: mocks.getStagedCommitContext, getStatus: mocks.getStatus })) +vi.mock('../git/conflict-status', async () => ({ + ...(await vi.importActual('../git/conflict-status')), + abortMerge: mocks.abortMerge, + abortRebase: mocks.abortRebase +})) + +vi.mock('../git/git-commit-operations', async () => ({ + ...(await vi.importActual('../git/git-commit-operations')), + getStagedCommitContext: mocks.getStagedCommitContext +})) + vi.mock('../git/checkout', () => ({ checkoutBranch: mocks.checkoutBranch, listLocalBranches: mocks.listLocalBranches diff --git a/src/main/runtime/orca-runtime-git.ts b/src/main/runtime/orca-runtime-git.ts index c90f6cfef27..abe0c92d5df 100644 --- a/src/main/runtime/orca-runtime-git.ts +++ b/src/main/runtime/orca-runtime-git.ts @@ -26,27 +26,21 @@ import { import { withLinkedIssueDraftContext } from '../../shared/source-control-ai-action-variables' import type { SourceControlAiOperation } from '../../shared/source-control-ai-types' import type { GitProviderStatusOptions } from '../providers/types' -import { getRemoteCommitUrl, getRemoteFileUrl } from '../git/repo' +import { getRemoteCommitUrl, getRemoteFileUrl } from '../git/repository-remotes' +import { abortMerge, abortRebase, detectConflictOperation } from '../git/conflict-status' +import { bulkDiscardChanges, discardChanges } from '../git/git-discard-changes' import { - abortMerge, - abortRebase, - bulkDiscardChanges, bulkStageFiles, bulkUnstageFiles, - commitChanges, - detectConflictOperation, - discardChanges, - getBranchCompare, - getBranchDiff, - getCommitCompare, - getCommitDiff, - getDiff, - getStagedCommitContext, - getStatus as getGitStatus, - getSubmoduleStatus as getGitSubmoduleStatus, stageFile, unstageFile -} from '../git/status' +} from '../git/git-index-mutations' +import { commitChanges, getStagedCommitContext } from '../git/git-commit-operations' +import { getBranchCompare, getBranchDiff } from '../git/branch-comparison' +import { getCommitCompare, getCommitDiff } from '../git/commit-comparison' +import { getDiff } from '../git/status-file-diff' +import { getStatus as getGitStatus } from '../git/status' +import { getSubmoduleStatus as getGitSubmoduleStatus } from '../git/submodule-status' import { checkoutBranch, listLocalBranches } from '../git/checkout' import type { RuntimeGitCheckoutResult, RuntimeGitLocalBranches } from '../../shared/runtime-types' import { getHistory as getGitHistory } from '../git/history' diff --git a/src/main/runtime/orca-runtime-linked-issue-live-meta.integration.test.ts b/src/main/runtime/orca-runtime-linked-issue-live-meta.integration.test.ts index 9b9bcfac784..8309699837e 100644 --- a/src/main/runtime/orca-runtime-linked-issue-live-meta.integration.test.ts +++ b/src/main/runtime/orca-runtime-linked-issue-live-meta.integration.test.ts @@ -3,9 +3,9 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { WorktreeMeta } from '../../shared/worktree/meta-types' -import type * as GitStatusModule from '../git/status' +import type * as GitCommitOperationsModule from '../git/git-commit-operations' import type * as CommitMessageTextGenerationModule from '../text-generation/commit-message-text-generation' -import type * as WorktreeModule from '../git/worktree' +import type * as WorktreeListingModule from '../git/worktree-listing' import { OrcaRuntimeService } from './orca-runtime' const mocks = vi.hoisted(() => ({ @@ -15,14 +15,14 @@ const mocks = vi.hoisted(() => ({ resolveCommitMessageSettings: vi.fn() })) -vi.mock('../git/worktree', async () => ({ - ...(await vi.importActual('../git/worktree')), +vi.mock('../git/worktree-listing', async () => ({ + ...(await vi.importActual('../git/worktree-listing')), listWorktrees: mocks.listWorktrees, listWorktreesStrict: mocks.listWorktrees })) -vi.mock('../git/status', async () => ({ - ...(await vi.importActual('../git/status')), +vi.mock('../git/git-commit-operations', async () => ({ + ...(await vi.importActual('../git/git-commit-operations')), getStagedCommitContext: mocks.getStagedCommitContext })) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 3688fec9c39..a82bc273fc9 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -32,20 +32,17 @@ import { const ORIGIN_REMOTE_URL = 'git@example.com:group/repo.git' const ORIGIN_HEAD_COMPONENT = reviewHeadRemoteRefComponent('origin', ORIGIN_REMOTE_URL) import { detectAgentStatusFromTitle, MAX_OSC_TITLE_CHARS } from '../../shared/agent-detection' -import { - addSparseWorktree, - addWorktree, - assertWorktreeCleanForRemoval, - listWorktrees, - listWorktreesStrict, - removeWorktree -} from '../git/worktree' +import { addSparseWorktree, addWorktree, removeWorktree } from '../git/worktree' + +import { listWorktrees, listWorktreesStrict } from '../git/worktree-listing' +import { assertWorktreeCleanForRemoval } from '../git/worktree-removal-preflight' +import * as gitProcessLaunch from '../git/git-process-launch' import * as gitRunner from '../git/runner' import { WORKTREE_PROCESS_SWEEP_TIMEOUT_MS, WORKTREE_TEARDOWN_RPC_MARGIN_MS } from './worktree-teardown' -import { clearSubmodulePathsCacheForTests, listSubmodulePaths } from '../git/status' +import { clearSubmodulePathsCacheForTests, listSubmodulePaths } from '../git/submodule-paths' import { getEffectiveHooks, hasHooksFile, loadHooks, parseOrcaYaml, runHook } from '../hooks' import { createSetupRunnerScript, resolveSetupRunnerShell } from '../worktree-runner-script' import { @@ -53,7 +50,8 @@ import { getDefaultTabsLaunch, shouldRunSetupForCreate } from '../effective-hook-config' -import { getBaseRefDefault, getBranchConflictKind } from '../git/repo' +import { getBaseRefDefault } from '../git/default-base-ref' +import { getBranchConflictKind } from '../git/branch-conflict-detection' import { OrchestrationDb } from './orchestration/db' import type { MessagePriority, MessageRow, MessageType } from './orchestration/types' import { @@ -429,12 +427,18 @@ const { }) vi.mock('../git/worktree', () => ({ - listWorktrees: vi.fn().mockResolvedValue(MOCK_GIT_WORKTREES), - listWorktreesStrict: vi.fn().mockResolvedValue(MOCK_GIT_WORKTREES), - assertWorktreeCleanForRemoval: vi.fn().mockResolvedValue(undefined), addSparseWorktree: addSparseWorktreeMock, addWorktree: addWorktreeMock, - removeWorktree: removeWorktreeMock, + removeWorktree: removeWorktreeMock +})) +vi.mock('../git/worktree-listing', () => ({ + listWorktrees: vi.fn().mockResolvedValue(MOCK_GIT_WORKTREES), + listWorktreesStrict: vi.fn().mockResolvedValue(MOCK_GIT_WORKTREES) +})) +vi.mock('../git/worktree-removal-preflight', () => ({ + assertWorktreeCleanForRemoval: vi.fn().mockResolvedValue(undefined) +})) +vi.mock('../git/worktree-branch-cleanup', () => ({ forceDeleteLocalBranch: forceDeleteLocalBranchMock })) @@ -659,7 +663,7 @@ vi.mock('../github/issues', async (importOriginal) => { }) // Why: CLI worktree creation resolves a default against fabricated repo paths, so keep the async resolver deterministic. -vi.mock('../git/repo', async (importOriginal) => { +vi.mock('../git/default-base-ref', async (importOriginal) => { const actual = (await importOriginal()) as Record const actualGetBaseRefDefault = actual.getBaseRefDefault as ( path: string, @@ -667,16 +671,20 @@ vi.mock('../git/repo', async (importOriginal) => { ) => Promise return { ...actual, - // Why: fabricated local test repos need a deterministic default, while WSL coverage must still exercise the real async Git-options path. + // Fabricated local repos need a deterministic default; WSL coverage still exercises real Git options. getBaseRefDefault: vi .fn() .mockImplementation((path: string, options?: { wslDistro?: string }) => options?.wslDistro ? actualGetBaseRefDefault(path, options) : Promise.resolve('origin/main') - ), - getBranchConflictKind: vi.fn().mockResolvedValue(null) + ) } }) +vi.mock('../git/branch-conflict-detection', async (importOriginal) => ({ + ...((await importOriginal()) as Record), + getBranchConflictKind: vi.fn().mockResolvedValue(null) +})) + vi.mock('../git/git-username', async () => { const actual = await vi.importActual('../git/git-username') return { ...actual, resolveLocalGitUsername: resolveLocalGitUsernameMock } @@ -8387,7 +8395,7 @@ describe('OrcaRuntimeService', () => { it('keeps project clone setup on the cloned host-qualified repo', async () => { const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-project-clone-')) const clonePath = join(destination, 'orca') - const spawnSpy = vi.spyOn(gitRunner, 'gitSpawnAfterWindowsEnvironmentReady') + const spawnSpy = vi.spyOn(gitProcessLaunch, 'gitSpawnAfterWindowsEnvironmentReady') const repos: Record[] = [] getRepoUpstreamMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' }) const runtimeStore = { @@ -8455,7 +8463,7 @@ describe('OrcaRuntimeService', () => { mkdirSync(existingFolder, { recursive: true }) execFileSync('git', ['init'], { cwd: existingFolder, stdio: 'ignore' }) const spawnSpy = vi - .spyOn(gitRunner, 'gitSpawnAfterWindowsEnvironmentReady') + .spyOn(gitProcessLaunch, 'gitSpawnAfterWindowsEnvironmentReady') .mockImplementation(() => { // Why: unreachable while the guard holds; stubbed so a regression records the call // instead of shelling out to a real network clone. @@ -8512,7 +8520,7 @@ describe('OrcaRuntimeService', () => { it('adopts public clone repos into host-qualified project setup', async () => { const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-project-clone-')) const clonePath = join(destination, 'orca') - const spawnSpy = vi.spyOn(gitRunner, 'gitSpawnAfterWindowsEnvironmentReady') + const spawnSpy = vi.spyOn(gitProcessLaunch, 'gitSpawnAfterWindowsEnvironmentReady') const repos: Record[] = [] getRepoUpstreamMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' }) const runtimeStore = { @@ -8582,7 +8590,7 @@ describe('OrcaRuntimeService', () => { it('keeps project clone repos split by runtime host on the same clone path', async () => { const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-project-clone-')) const clonePath = join(destination, 'orca') - const spawnSpy = vi.spyOn(gitRunner, 'gitSpawnAfterWindowsEnvironmentReady') + const spawnSpy = vi.spyOn(gitProcessLaunch, 'gitSpawnAfterWindowsEnvironmentReady') const repos: Record[] = [ { id: 'repo-host-a', @@ -8754,7 +8762,7 @@ describe('OrcaRuntimeService', () => { }) it('defaults runtime cloneRepo badgeColor to DEFAULT_REPO_BADGE_COLOR', async () => { - const spawnSpy = vi.spyOn(gitRunner, 'gitSpawnAfterWindowsEnvironmentReady') + const spawnSpy = vi.spyOn(gitProcessLaunch, 'gitSpawnAfterWindowsEnvironmentReady') const added: Record[] = [] const colorStore = { ...store, @@ -8789,7 +8797,7 @@ describe('OrcaRuntimeService', () => { }) it('drops a same-path negative submodule cache before runtime cloneRepo', async () => { - const spawnSpy = vi.spyOn(gitRunner, 'gitSpawnAfterWindowsEnvironmentReady') + const spawnSpy = vi.spyOn(gitProcessLaunch, 'gitSpawnAfterWindowsEnvironmentReady') const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-reclone-')) const clonePath = join(destination, 'reclone') const added: Record[] = [] @@ -8826,7 +8834,7 @@ describe('OrcaRuntimeService', () => { }) it('preserves existing badgeColor on runtime cloneRepo folder->git dedupe upgrade', async () => { - const spawnSpy = vi.spyOn(gitRunner, 'gitSpawnAfterWindowsEnvironmentReady') + const spawnSpy = vi.spyOn(gitProcessLaunch, 'gitSpawnAfterWindowsEnvironmentReady') spawnSpy.mockImplementation(() => { const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } proc.stderr = new EventEmitter() @@ -8924,7 +8932,7 @@ describe('OrcaRuntimeService', () => { }) it('rejects runtime cloneRepo dot-segment URLs before spawning git', async () => { - const spawnSpy = vi.spyOn(gitRunner, 'gitSpawnAfterWindowsEnvironmentReady') + const spawnSpy = vi.spyOn(gitProcessLaunch, 'gitSpawnAfterWindowsEnvironmentReady') const runtime = createRuntime() const tempRoot = await mkdtemp(join(tmpdir(), 'orca-runtime-clone-')) const destination = join(tempRoot, 'destination') @@ -8941,7 +8949,7 @@ describe('OrcaRuntimeService', () => { }) it('rejects runtime cloneRepo parent-segment URLs before spawning git', async () => { - const spawnSpy = vi.spyOn(gitRunner, 'gitSpawnAfterWindowsEnvironmentReady') + const spawnSpy = vi.spyOn(gitProcessLaunch, 'gitSpawnAfterWindowsEnvironmentReady') const runtime = createRuntime() const tempRoot = await mkdtemp(join(tmpdir(), 'orca-runtime-clone-')) const destination = join(tempRoot, 'destination') @@ -8958,7 +8966,7 @@ describe('OrcaRuntimeService', () => { }) it('removes an owned runtime clone target when git exits unsuccessfully', async () => { - const spawnSpy = vi.spyOn(gitRunner, 'gitSpawnAfterWindowsEnvironmentReady') + const spawnSpy = vi.spyOn(gitProcessLaunch, 'gitSpawnAfterWindowsEnvironmentReady') const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } proc.stderr = new EventEmitter() spawnSpy.mockResolvedValue(proc as never) @@ -8985,7 +8993,7 @@ describe('OrcaRuntimeService', () => { }) it('preserves an existing runtime clone target when git exits unsuccessfully', async () => { - const spawnSpy = vi.spyOn(gitRunner, 'gitSpawnAfterWindowsEnvironmentReady') + const spawnSpy = vi.spyOn(gitProcessLaunch, 'gitSpawnAfterWindowsEnvironmentReady') const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } proc.stderr = new EventEmitter() spawnSpy.mockResolvedValue(proc as never) @@ -9012,7 +9020,7 @@ describe('OrcaRuntimeService', () => { }) it('skips runtime clone failure cleanup when the owned target is replaced', async () => { - const spawnSpy = vi.spyOn(gitRunner, 'gitSpawnAfterWindowsEnvironmentReady') + const spawnSpy = vi.spyOn(gitProcessLaunch, 'gitSpawnAfterWindowsEnvironmentReady') const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } proc.stderr = new EventEmitter() spawnSpy.mockResolvedValue(proc as never) @@ -9041,7 +9049,7 @@ describe('OrcaRuntimeService', () => { }) it('serializes concurrent runtime clones for the same target', async () => { - const spawnSpy = vi.spyOn(gitRunner, 'gitSpawnAfterWindowsEnvironmentReady') + const spawnSpy = vi.spyOn(gitProcessLaunch, 'gitSpawnAfterWindowsEnvironmentReady') const firstProc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } firstProc.stderr = new EventEmitter() spawnSpy.mockResolvedValueOnce(firstProc as never) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index aac73f5e7f4..ecef682ae05 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -105,13 +105,10 @@ import { type AgentPromptActivity, verifyAgentPromptSubmission } from './agent-prompt-submission-verification' -import { - awaitWindowsHostGitEnvironmentReady, - gitExecFileAsync, - gitSpawnAfterWindowsEnvironmentReady, - nonInteractiveGitEnv -} from '../git/runner' -import { runWithGitReadCacheInvalidation } from '../git/status' +import { awaitWindowsHostGitEnvironmentReady, gitExecFileAsync } from '../git/runner' +import { gitSpawnAfterWindowsEnvironmentReady } from '../git/git-process-launch' +import { nonInteractiveGitEnv } from '../git/git-environment-policy' +import { runWithGitReadCacheInvalidation } from '../git/git-read-cache' import { wakeFolderRepoGitUpgradeWatch } from '../ipc/folder-repo-git-upgrade-wake' import { cleanupClaimedCloneTarget, @@ -964,24 +961,29 @@ import { searchIssues as searchJiraIssues, updateIssue as updateJiraIssue } from '../jira/issues' +import { getProjectViewTable, listProjectViews } from '../github/project-view' +import { listAccessibleProjects } from '../github/project-view/accessible-project-discovery' +import { resolveProjectRef } from '../github/project-view/project-reference' +import { updateIssueBySlug } from '../github/project-view/mutations' import { clearProjectItemFieldValue, - getProjectViewTable, - getWorkItemDetailsBySlug, - listAccessibleProjects, - listProjectViews, - resolveProjectRef, + updateProjectItemFieldValue +} from '../github/project-view/project-item-field-mutations' +import { updatePullRequestBySlug } from '../github/project-view/pull-request-mutations' +import { addIssueCommentBySlug, deleteIssueCommentBySlug, + updateIssueCommentBySlug +} from '../github/project-view/issue-comment-mutations' +import { listAssignableUsersBySlug, + listLabelsBySlug +} from '../github/project-view/repository-label-assignee-sources' +import { listIssueTypesBySlug, - listLabelsBySlug, - updateIssueCommentBySlug, - updateIssueBySlug, - updateIssueTypeBySlug, - updateProjectItemFieldValue, - updatePullRequestBySlug -} from '../github/project-view' + updateIssueTypeBySlug +} from '../github/project-view/repository-issue-types' +import { getWorkItemDetailsBySlug } from '../github/project-view/work-item-details-query' import type { ClearProjectItemFieldArgs, GetProjectViewTableArgs, @@ -1000,39 +1002,32 @@ import type { UpdateProjectItemFieldArgs, UpdatePullRequestBySlugArgs } from '../../shared/github/project-request-types' +import { isGitRepo, getRepoName } from '../git/repo' import { getBaseRefDefault, - getDefaultRemote, - getBranchConflictKind, - isGitRepo, - getRepoName, + resolveDefaultBaseRefViaExec, + resolveDefaultBaseRefWithLocalGit +} from '../git/default-base-ref' +import { getDefaultRemote, getRemoteCount, parseRemoteCount } from '../git/repository-remotes' +import { getBranchConflictKind } from '../git/branch-conflict-detection' +import { searchBaseRefDetails, - getRemoteCount, normalizeRefSearchQuery, parseAndFilterSearchRefDetails, - parseRemoteCount, - resolveDefaultBaseRefViaExec, - resolveDefaultBaseRefWithLocalGit, buildSearchBaseRefsArgv, - isForEachRefExcludeUnsupportedError, - mergeBaseRefSearchResultGroups, - getRemoteDrift, - getRecentDriftSubjects -} from '../git/repo' + mergeBaseRefSearchResultGroups +} from '../git/base-ref-search' +import { isForEachRefExcludeUnsupportedError } from '../../shared/git-ref-command-capabilities' +import { getRemoteDrift, getRecentDriftSubjects } from '../git/remote-drift' import { hasCommitObjectViaGitExec } from '../git/commit-object-ref' import { hasWorktreeBaseCommitRef } from '../git/worktree-base-ref-probe' import { resolveLocalGitUsername } from '../git/git-username' import { getSshGitCapabilityCache } from '../git/git-capability-state' -import { - listWorktrees, - listWorktreesStrict, - addWorktree, - addSparseWorktree, - assertWorktreeCleanForRemoval, - forceDeleteLocalBranch, - removeWorktree -} from '../git/worktree' -import type { AddWorktreeOptions, AddWorktreeResult } from '../git/worktree' +import { addSparseWorktree, addWorktree, removeWorktree } from '../git/worktree' +import { forceDeleteLocalBranch } from '../git/worktree-branch-cleanup' +import { listWorktrees, listWorktreesStrict } from '../git/worktree-listing' +import type { AddWorktreeOptions, AddWorktreeResult } from '../git/worktree-operation-contracts' +import { assertWorktreeCleanForRemoval } from '../git/worktree-removal-preflight' import { isENOENT } from '../ipc/filesystem-path-containment' import { invalidateAuthorizedRootsCache } from '../ipc/registered-worktree-roots-cache' import { diff --git a/src/main/runtime/remote-desktop-driver.test.ts b/src/main/runtime/remote-desktop-driver.test.ts index bf2370ee924..b1ed5c50a1c 100644 --- a/src/main/runtime/remote-desktop-driver.test.ts +++ b/src/main/runtime/remote-desktop-driver.test.ts @@ -31,14 +31,14 @@ vi.mock('../ipc/worktree-logic', async (importOriginal) => { vi.mock('../ipc/registered-worktree-roots-cache', () => ({ invalidateAuthorizedRootsCache: vi.fn() })) -vi.mock('../git/repo', async (importOriginal) => { - const actual = (await importOriginal()) as Record - return { - ...actual, - getDefaultBaseRef: vi.fn().mockReturnValue('origin/main'), - getBranchConflictKind: vi.fn().mockResolvedValue(null) - } -}) +vi.mock('../git/default-base-ref', async (importOriginal) => ({ + ...((await importOriginal()) as Record), + getDefaultBaseRef: vi.fn().mockReturnValue('origin/main') +})) +vi.mock('../git/branch-conflict-detection', async (importOriginal) => ({ + ...((await importOriginal()) as Record), + getBranchConflictKind: vi.fn().mockResolvedValue(null) +})) vi.mock('../git/git-username', async () => { const actual = await vi.importActual('../git/git-username') return { ...actual, resolveLocalGitUsername: vi.fn(async () => '') } diff --git a/src/main/runtime/repo-worktree-resolution-scan.test.ts b/src/main/runtime/repo-worktree-resolution-scan.test.ts index 9d85d07fd58..b18588a657f 100644 --- a/src/main/runtime/repo-worktree-resolution-scan.test.ts +++ b/src/main/runtime/repo-worktree-resolution-scan.test.ts @@ -1,8 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { listWorktreesStrict } from '../git/worktree' +import { listWorktreesStrict } from '../git/worktree-listing' import { scanLocalRepoWorktreesForResolution } from './repo-worktree-resolution-scan' -vi.mock('../git/worktree', () => ({ listWorktreesStrict: vi.fn() })) +vi.mock('../git/worktree-listing', () => ({ listWorktreesStrict: vi.fn() })) describe('scanLocalRepoWorktreesForResolution', () => { beforeEach(() => { diff --git a/src/main/runtime/repo-worktree-resolution-scan.ts b/src/main/runtime/repo-worktree-resolution-scan.ts index 052118cf2bf..aaf5ff30c4c 100644 --- a/src/main/runtime/repo-worktree-resolution-scan.ts +++ b/src/main/runtime/repo-worktree-resolution-scan.ts @@ -1,5 +1,5 @@ import type { GitWorktreeInfo } from '../../shared/worktree/types' -import { listWorktreesStrict } from '../git/worktree' +import { listWorktreesStrict } from '../git/worktree-listing' import type { LocalProjectWorktreeGitOptions } from '../project-runtime-git-options' export type RuntimeWorktreeScanResult = diff --git a/src/main/runtime/runtime-rpc-mobile-terminal-streaming.test.ts b/src/main/runtime/runtime-rpc-mobile-terminal-streaming.test.ts index e558d19b54b..3ea1c2fb315 100644 --- a/src/main/runtime/runtime-rpc-mobile-terminal-streaming.test.ts +++ b/src/main/runtime/runtime-rpc-mobile-terminal-streaming.test.ts @@ -24,7 +24,7 @@ import { } from './runtime-rpc-mobile-ws-test-harness' import { makeStore } from './runtime-rpc-worktree-store-fixtures' -vi.mock('../git/worktree', () => { +vi.mock('../git/worktree-listing', () => { const worktrees = [ { path: '/tmp/worktree-a', diff --git a/src/main/runtime/runtime-rpc-worktree-queries.test.ts b/src/main/runtime/runtime-rpc-worktree-queries.test.ts index 28b2f9e46db..4a860238af1 100644 --- a/src/main/runtime/runtime-rpc-worktree-queries.test.ts +++ b/src/main/runtime/runtime-rpc-worktree-queries.test.ts @@ -8,7 +8,7 @@ import { OrcaRuntimeRpcServer } from './runtime-rpc' import { sendRequest } from './runtime-rpc-test-harness' import { makeStore } from './runtime-rpc-worktree-store-fixtures' -vi.mock('../git/worktree', () => { +vi.mock('../git/worktree-listing', () => { const worktrees = [ { path: '/tmp/worktree-a', diff --git a/src/main/runtime/worktree-ps-degraded-repo-scan.test.ts b/src/main/runtime/worktree-ps-degraded-repo-scan.test.ts index 8b01d20e7a3..61ed77c84c7 100644 --- a/src/main/runtime/worktree-ps-degraded-repo-scan.test.ts +++ b/src/main/runtime/worktree-ps-degraded-repo-scan.test.ts @@ -24,7 +24,7 @@ vi.mock('../providers/ssh-git-dispatch', () => ({ })) const listWorktreesStrictMock = vi.hoisted(() => vi.fn()) -vi.mock('../git/worktree', async (importOriginal) => ({ +vi.mock('../git/worktree-listing', async (importOriginal) => ({ ...(await importOriginal>()), listWorktreesStrict: listWorktreesStrictMock })) diff --git a/src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts b/src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts index 1aa49b76d8e..94b3336724a 100644 --- a/src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts +++ b/src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts @@ -27,7 +27,7 @@ vi.mock('../providers/ssh-git-dispatch', () => ({ })) const listWorktreesStrictMock = vi.hoisted(() => vi.fn()) -vi.mock('../git/worktree', async (importOriginal) => ({ +vi.mock('../git/worktree-listing', async (importOriginal) => ({ ...(await importOriginal>()), listWorktreesStrict: listWorktreesStrictMock })) diff --git a/src/main/source-control/hosted-review-creation-eligibility.test.ts b/src/main/source-control/hosted-review-creation-eligibility.test.ts index 2cf262e8cca..cecef004804 100644 --- a/src/main/source-control/hosted-review-creation-eligibility.test.ts +++ b/src/main/source-control/hosted-review-creation-eligibility.test.ts @@ -1,3 +1,5 @@ +vi.mock('../git/gitlab-cli-runner', () => ({ glabExecFileAsync: glabExecFileAsyncMock })) +vi.mock('../git/github-cli-runner', () => ({ ghExecFileAsync: ghExecFileAsyncMock })) import { beforeEach, describe, expect, it, vi } from 'vitest' const { diff --git a/src/main/source-control/hosted-review-creation-gitlab-self-hosted.test.ts b/src/main/source-control/hosted-review-creation-gitlab-self-hosted.test.ts index e0bb2e49161..7b2164548e6 100644 --- a/src/main/source-control/hosted-review-creation-gitlab-self-hosted.test.ts +++ b/src/main/source-control/hosted-review-creation-gitlab-self-hosted.test.ts @@ -28,6 +28,9 @@ vi.mock('../git/runner', () => ({ ghExecFileAsync: ghExecFileAsyncMock, extractExecError: vi.fn() })) +vi.mock('../git/github-cli-runner', () => ({ ghExecFileAsync: ghExecFileAsyncMock })) +vi.mock('../git/gitlab-cli-runner', () => ({ glabExecFileAsync: glabExecFileAsyncMock })) +vi.mock('../git/exec-error', () => ({ extractExecError: vi.fn() })) vi.mock('../github/client', () => ({ createGitHubPullRequest: vi.fn(), diff --git a/src/main/source-control/hosted-review-creation-shared-symlinks.test.ts b/src/main/source-control/hosted-review-creation-shared-symlinks.test.ts index ff0fcf6e02d..30310aa92cb 100644 --- a/src/main/source-control/hosted-review-creation-shared-symlinks.test.ts +++ b/src/main/source-control/hosted-review-creation-shared-symlinks.test.ts @@ -1,3 +1,5 @@ +vi.mock('../git/gitlab-cli-runner', () => ({ glabExecFileAsync: glabExecFileAsyncMock })) +vi.mock('../git/github-cli-runner', () => ({ ghExecFileAsync: ghExecFileAsyncMock })) import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' diff --git a/src/main/source-control/hosted-review-creation.test.ts b/src/main/source-control/hosted-review-creation.test.ts index 83df9010d65..64120ba54ea 100644 --- a/src/main/source-control/hosted-review-creation.test.ts +++ b/src/main/source-control/hosted-review-creation.test.ts @@ -1,3 +1,5 @@ +vi.mock('../git/gitlab-cli-runner', () => ({ glabExecFileAsync: glabExecFileAsyncMock })) +vi.mock('../git/github-cli-runner', () => ({ ghExecFileAsync: ghExecFileAsyncMock })) import { beforeEach, describe, expect, it, vi } from 'vitest' const { diff --git a/src/main/source-control/hosted-review-creation.ts b/src/main/source-control/hosted-review-creation.ts index 9c9be75ab24..be99446c540 100644 --- a/src/main/source-control/hosted-review-creation.ts +++ b/src/main/source-control/hosted-review-creation.ts @@ -1,3 +1,5 @@ +import { glabExecFileAsync } from '../git/gitlab-cli-runner' +import { ghExecFileAsync } from '../git/github-cli-runner' /* eslint-disable max-lines -- Why: detection, eligibility, and creation preflight share one boundary so gating can't drift. */ import type { CreateHostedReviewInput, @@ -22,18 +24,17 @@ import { isBitbucketReviewCreationAuthenticated } from '../bitbucket/pull-reques import { getEnterpriseGitHubRepoSlug } from '../github/github-enterprise-repository' import { getRepoSlug } from '../github/client' import { isDefaultGitHubHost } from '../../shared/github/repository-identity-key' -import { acquire, ghExecFileAsync, gitExecFileAsync, release } from '../github/gh-utils' +import { acquire, gitExecFileAsync, release } from '../github/gh-utils' import { isNoUpstreamError, normalizeGitErrorMessage } from '../../shared/git-remote-error' import type { GitUpstreamStatus } from '../../shared/git-status-types' -import { gitOptionalLocksDisabledEnv } from '../git/runner' +import { gitOptionalLocksDisabledEnv } from '../git/git-environment-policy' import { parsePorcelainV1Records, type PorcelainV1Record } from '../git/porcelain-v1-records' import { findExistingWorktreeSymlinkPaths } from '../git/worktree-symlink-detection' -import { resolveDefaultBaseRefViaExec } from '../git/repo' +import { resolveDefaultBaseRefViaExec } from '../git/default-base-ref' import { getUpstreamStatus } from '../git/upstream' import { getProjectSlug } from '../gitlab/client' import { acquire as acquireGlab, - glabExecFileAsync, glabRepoExecOptions, release as releaseGlab } from '../gitlab/gl-utils' diff --git a/src/main/source-control/repo-default-branch.ts b/src/main/source-control/repo-default-branch.ts index b5c4f77d2c8..b0a819bc969 100644 --- a/src/main/source-control/repo-default-branch.ts +++ b/src/main/source-control/repo-default-branch.ts @@ -1,4 +1,4 @@ -import { resolveDefaultBaseRefViaExec } from '../git/repo' +import { resolveDefaultBaseRefViaExec } from '../git/default-base-ref' import { gitExecFileAsync } from '../git/runner' import { getSshGitProvider } from '../providers/ssh-git-dispatch' import type { HostedReviewLocalGitOptions } from './hosted-review-git-options' diff --git a/src/main/worktree-create-base-prefetch.ts b/src/main/worktree-create-base-prefetch.ts index a7665fc4e15..0b14c43853b 100644 --- a/src/main/worktree-create-base-prefetch.ts +++ b/src/main/worktree-create-base-prefetch.ts @@ -2,7 +2,7 @@ import { isFolderRepo } from '../shared/repo-kind' import type { Repo } from '../shared/repo-types' import { hasLocalCommitObject, isFullGitObjectId } from './git/commit-object-ref' import { hasWorktreeBaseCommitRef } from './git/worktree-base-ref-probe' -import { getBaseRefDefault } from './git/repo' +import { getBaseRefDefault } from './git/default-base-ref' import { getSshGitProvider } from './providers/ssh-git-dispatch' import { prefetchRemoteWorktreeCreateBase } from './ipc/worktree-remote' import { resolveWorktreeCreateBase } from './worktree-create-base' diff --git a/src/main/worktree-runner-script.ts b/src/main/worktree-runner-script.ts index c3a32cc03b3..fe56e809b4d 100644 --- a/src/main/worktree-runner-script.ts +++ b/src/main/worktree-runner-script.ts @@ -5,7 +5,7 @@ import { nativeWindowsPathToPosixShellPath } from '../shared/setup-runner-comman import { scriptDeclaresPosixShell } from '../shared/setup-script-shebang' import { resolveWindowsShellStartupFamily } from '../shared/windows-terminal-shell' import { resolveWindowsGitBashShellPath } from './git-bash' -import { gitExecFileSync } from './git/runner' +import { gitExecFileSync } from './git/git-process-launch' import { isWslPath, toWindowsWslPath, toLinuxPath } from './wsl' import { getHookRuntimeTarget, getHookWslContext } from './hook-runtime-target' import { SETUP_RUNNER_PATH_ENV_KEYS, getSetupRunnerEnvVars } from './setup-hook-env-vars' diff --git a/src/main/wsl/wsl-executable.ts b/src/main/wsl/wsl-executable.ts new file mode 100644 index 00000000000..ed55150f99a --- /dev/null +++ b/src/main/wsl/wsl-executable.ts @@ -0,0 +1,2 @@ +/** Canonical Windows-side WSL launcher owned by the WSL process boundary. */ +export const WSL_EXECUTABLE = 'wsl.exe' diff --git a/src/shared/child-process/node-process-execution.ts b/src/shared/child-process/node-process-execution.ts new file mode 100644 index 00000000000..e6be0cd8246 --- /dev/null +++ b/src/shared/child-process/node-process-execution.ts @@ -0,0 +1,51 @@ +import { + execFile as nodeExecFile, + execFileSync as nodeExecFileSync, + spawn as nodeSpawn, + type ChildProcess, + type ExecFileException, + type ExecFileOptions, + type SpawnOptions +} from 'node:child_process' + +export type SpawnedProcess = ChildProcess +export type SpawnedProcessOptions = SpawnOptions +export type ExecutedFileOptions = ExecFileOptions +export type ExecutedFileCallback = ( + error: ExecFileException | null, + stdout: string | Buffer, + stderr: string | Buffer +) => void + +/** Spawn with Node semantics while retaining the main-process no-console invariant. */ +export function spawnNodeProcess( + command: string, + args: readonly string[], + options: SpawnOptions +): ChildProcess { + return nodeSpawn(command, [...args], { ...options, windowsHide: true }) +} + +/** Execute a file with Node callback semantics and a hidden Windows console. */ +export function execNodeFile( + command: string, + args: readonly string[], + options: ExecFileOptions, + callback: ExecutedFileCallback +): ChildProcess { + return nodeExecFile( + command, + [...args], + { ...options, windowsHide: true }, + callback as Parameters[3] + ) +} + +/** Synchronous counterpart used only by genuinely synchronous Git probes. */ +export function execNodeFileSync( + command: string, + args: readonly string[], + options: Parameters[2] +): string | Buffer { + return nodeExecFileSync(command, [...args], { ...options, windowsHide: true }) +}