From 328caa2160a0308b74329ba82b7ecc5bd82a08f5 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:05:37 -0700 Subject: [PATCH] fix(git): reduce queries and preserve data across execution hosts (#24602) * fix(git): reduce queries and preserve data across execution hosts * fix(ci): exercise pinned Git and serialize mobile dependency entrypoints * fix(relay): preserve fresh diff retries after hung shared reads * test(git): wait for fetch barrier before canceling preparation * fix(i18n): describe index-preserving discard in every locale * fix(git): retain clone diagnostics and allow WSL policy startup * test(git): refresh default-base and branch-safety fixtures --- .../prepare-git-compatibility/action.yml | 9 +- .github/workflows/mobile.yml | 6 +- .github/workflows/pr.yml | 7 +- .../ci-background-step-barriers.test.mjs | 4 +- ...git-binary-compatibility-workflow.test.mjs | 15 +- src/main/azure-devops/client.test.ts | 7 +- src/main/bitbucket/client.test.ts | 7 +- .../git-command-timeout.test.ts | 1 + .../git/command-runner/git-command-timeout.ts | 24 +- .../git-exec-admission-lifetime.test.ts | 24 +- src/main/git/command-runner/git-exec-file.ts | 26 +- .../git/command-runner/git-process-env.ts | 2 +- src/main/git/command-runner/git-spawn.ts | 11 +- .../git/command-runner/git-ssh-policy-env.ts | 175 ++-------- .../git-subprocess-admission.test.ts | 2 +- .../git-subprocess-admission.ts | 304 +----------------- .../git/git-network-safety-real-git.test.ts | 143 ++++++++ src/main/git/remote.test.ts | 125 +++---- src/main/git/remove-worktree.test.ts | 25 +- ...repo-branch-conflict-batched-probe.test.ts | 6 + src/main/git/repo-branch-conflict.test.ts | 109 ++++++- src/main/git/repo-branch-conflict.ts | 30 +- src/main/git/repo-default-base-ref.ts | 62 +--- src/main/git/repo-default-remote.test.ts | 11 +- src/main/git/repo-detection-batching.test.ts | 229 +++++++++++++ src/main/git/repo-detection.ts | 203 ++++++++---- src/main/git/repo-remote-drift.test.ts | 2 +- src/main/git/repo.test.ts | 69 ++-- src/main/git/repo.ts | 11 +- .../git/runner-buffer-cancellation.test.ts | 97 ++++++ src/main/git/runner-command-exec.test.ts | 219 +------------ .../git/runner-network-ssh-policy.test.ts | 281 ++++++++++++++++ .../runner-windows-host-environment.test.ts | 8 + src/main/git/runner-wsl-direct-read.test.ts | 67 ++-- .../runner-wsl-login-shell-capture.test.ts | 6 +- .../git/runner-wsl-network-ssh-policy.test.ts | 218 +++++++++++++ .../blob-absence-real-git.test.ts | 135 ++++++++ .../source-control/branch-change-entries.ts | 127 +------- .../bulk-pathspec-command-line-budget.test.ts | 51 +-- .../bulk-pathspec-stdin.test.ts | 103 ++++++ .../git/source-control/discard-changes.ts | 29 +- .../effective-upstream-status-probe.ts | 6 +- src/main/git/source-control/file-diff.ts | 2 +- src/main/git/source-control/git-blob-read.ts | 30 +- .../staging-discard-index-safety.test.ts | 158 +++++++++ src/main/git/source-control/staging.ts | 30 +- .../git/source-control/status-line-stats.ts | 2 + .../git/source-control/submodule-status.ts | 38 +-- .../wsl-tracked-pathspec-banner.test.ts | 17 +- src/main/git/status-branch-compare.test.ts | 80 ++--- .../git/status-diff-settled-cache.test.ts | 9 +- src/main/git/status-diff.test.ts | 7 +- .../status-discard-and-bulk-staging.test.ts | 77 +++-- src/main/git/status-submodule.test.ts | 14 +- src/main/git/status-wsl-pathspecs.test.ts | 6 +- src/main/git/status.test.ts | 11 +- src/main/git/upstream.test.ts | 14 +- src/main/git/upstream.ts | 11 +- .../git/worktree-branch-removal-host.test.ts | 31 ++ src/main/git/worktree-branch-removal.ts | 31 +- ...ktree-create-git-executor-real-git.test.ts | 4 +- ...rktree-create-preparation-real-git.test.ts | 7 + src/main/git/worktree-graph-listing.test.ts | 4 + src/main/git/worktree-list-porcelain.test.ts | 4 + src/main/git/worktree-list-reader.ts | 10 +- src/main/git/worktree-removal.ts | 16 +- src/main/git/worktree-safety-real-git.test.ts | 284 ++++++++++++++++ src/main/git/wsl-direct-git-read-commands.ts | 186 +---------- src/main/gitea/client.test.ts | 7 +- .../github/default-branch-stale-pr.test.ts | 14 +- src/main/gitlab/client-mr-test-harness.ts | 7 +- .../ipc/repos-add-linked-worktree.test.ts | 7 + .../ipc/repos-remote-base-ref-queries.test.ts | 49 +-- src/main/ipc/repos-remote-test-harness.ts | 11 +- src/main/ipc/repos/local-repo-registration.ts | 14 +- src/main/ipc/repos/repo-clone-lifecycle.ts | 4 +- ...orktree-remote-ssh-branch-conflict.test.ts | 3 + src/main/ipc/worktree-remote.ts | 32 +- .../worktrees-ssh-base-ref-resolution.test.ts | 15 + ...rees-ssh-branch-conflict-suffixing.test.ts | 9 + ...worktrees-ssh-create-base-prefetch.test.ts | 106 +++++- ...ees-ssh-local-base-refresh-overlap.test.ts | 6 + .../worktrees-ssh-local-base-refresh.test.ts | 3 + .../register-worktree-forget-handlers.ts | 3 +- .../providers/working-directory-validation.ts | 19 +- .../orca-runtime-test-fixtures.spec.ts | 10 + .../hooks-and-hosted-review-part-03.spec.ts | 16 +- .../ssh-worktree-lifecycle-part-02.spec.ts | 13 +- .../ssh-worktree-lifecycle.spec.ts | 25 +- ...orktree-removal-and-reconciliation.spec.ts | 21 +- ...worktree-setup-and-startup-part-05.spec.ts | 19 +- .../repo-worktree-admin-fingerprint.test.ts | 11 + .../repo-worktree-admin-fingerprint.ts | 40 +-- .../runtime-git-status-admission.test.ts | 2 +- .../runtime-preserved-branch-cleanup.ts | 8 +- .../runtime-repository-clone-controller.ts | 4 +- ...hosted-review-creation-eligibility.test.ts | 33 +- .../repo-default-branch.test.ts | 36 +-- .../text-generation/pull-request-context.ts | 9 +- src/relay/git-buffer-overflow.ts | 29 +- src/relay/git-command-admission.test.ts | 145 +++++++++ src/relay/git-diff-cancellation.test.ts | 74 +++++ src/relay/git-handler-blob-readers.test.ts | 43 ++- src/relay/git-handler-branch-cleanup.ts | 21 +- src/relay/git-handler-branch-diff-ops.ts | 6 +- ...git-handler-clone-progress-capture.test.ts | 150 +++++++++ src/relay/git-handler-command-termination.ts | 92 ++++-- src/relay/git-handler-commit-diff-ops.ts | 131 +++----- src/relay/git-handler-commit-metadata.test.ts | 101 ++++++ .../git-handler-comparison-operations.ts | 78 +++-- src/relay/git-handler-diff-blobs.test.ts | 152 +++++++++ src/relay/git-handler-diff-retry.test.ts | 81 +++++ src/relay/git-handler-discard-operations.ts | 22 +- .../git-handler-object-diff-operations.ts | 16 +- src/relay/git-handler-operation-context.ts | 29 +- src/relay/git-handler-ops.ts | 47 ++- src/relay/git-handler-push-target.test.ts | 42 +-- src/relay/git-handler-read-operations.ts | 41 ++- src/relay/git-handler-registration.ts | 16 +- src/relay/git-handler-status-ops.ts | 14 +- src/relay/git-handler-submodule-ops.test.ts | 21 ++ src/relay/git-handler-submodule-ops.ts | 29 +- .../git-handler-working-tree-changes.test.ts | 22 +- .../git-handler-worktree-change-operations.ts | 32 +- src/relay/git-handler-worktree-list.ts | 9 +- src/relay/git-handler-worktree-operations.ts | 7 +- src/relay/git-handler-worktree-ops.test.ts | 16 +- src/relay/git-handler-worktree-paths.test.ts | 6 + src/relay/git-handler-worktree-remove.ts | 19 +- src/relay/git-handler.ts | 237 +++++++------- src/relay/git-porcelain-local-parity.test.ts | 2 + .../git-push-target-local-parity.test.ts | 41 +++ .../git-status-branch-line-total.test.ts | 35 +- .../git-status-upstream-negative-cache.ts | 4 +- src/relay/git-stdout-stream.test.ts | 181 ++++++++++- src/relay/git-stdout-stream.ts | 66 ++-- src/relay/relay-command-env.test.ts | 2 +- src/relay/relay-command-env.ts | 1 - ...ource-control-discard-confirmation.test.ts | 12 +- ...ource-control-discard-localization.test.ts | 32 ++ .../commit/discard-all-sequence.ts | 23 +- .../commit/discard-confirmation.ts | 13 +- .../commit/rename-mutation-paths.test.ts | 36 +++ ...e-control-entry-mutation-failures.test.tsx | 22 +- .../source-control/commit/use-bulk-actions.ts | 12 +- .../commit/use-entry-mutations.ts | 11 +- .../listing/section-file-list.tsx | 2 +- .../listing/uncommitted-entry-row.tsx | 4 +- .../listing/uncommitted-sections.tsx | 2 +- .../src/i18n/en-runtime-required.json | 11 +- src/renderer/src/i18n/locales/en.json | 4 + .../child-process-import-allowlist.txt | 2 - .../windows-console-visibility-allowlist.txt | 1 - .../child-process/bounded-output-sink.test.ts | 32 ++ .../child-process/bounded-output-sink.ts | 29 +- src/shared/child-process/process-spec.ts | 5 + .../child-process/run-process-bytes.test.ts | 57 ++++ src/shared/child-process/run-process.ts | 26 +- .../windows-console-visibility.test.ts | 2 +- .../git-admission-candidate-heap.ts | 0 .../git-admission-node-compatibility.test.ts | 25 ++ src/shared/git-admission-scheduler.ts | 303 +++++++++++++++++ .../git-admission-state.ts | 8 +- .../git-admission-waiter-queue.ts | 2 +- src/shared/git-binary-compatibility.test.ts | 175 +++++++++- src/shared/git-blob-absence.test.ts | 48 +++ src/shared/git-blob-absence.ts | 21 ++ src/shared/git-branch-line-total.test.ts | 2 + src/shared/git-branch-line-total.ts | 13 +- src/shared/git-change-list.test.ts | 109 +++++++ src/shared/git-change-list.ts | 54 ++++ src/shared/git-command-classification.ts | 181 +++++++++++ src/shared/git-command-failure-text.ts | 13 + src/shared/git-command-timeout.ts | 23 ++ src/shared/git-common-directory.ts | 64 ++++ src/shared/git-default-base-ref.ts | 37 +++ src/shared/git-effective-upstream.ts | 7 +- src/shared/git-fork-sync.test.ts | 14 +- src/shared/git-fork-sync.ts | 17 +- src/shared/git-history.ts | 2 + src/shared/git-pathspec-stdin.ts | 6 + src/shared/git-push-target-resolution.ts | 17 +- ...olution-binary-compatibility.test-cases.ts | 89 +++++ .../git-resolution-config-snapshot.test.ts | 120 +++++++ src/shared/git-rev-list-output.test.ts | 25 +- src/shared/git-rev-list-output.ts | 11 + src/shared/git-ssh-policy-env.test.ts | 62 ++++ src/shared/git-ssh-policy-env.ts | 162 ++++++++++ .../git-status-read-lease-expiry.test.ts | 186 +++++++++++ src/shared/git-status-read-lease-owner.ts | 42 ++- src/shared/git-worktree-admin.test.ts | 119 +++++++ src/shared/git-worktree-admin.ts | 127 ++++++++ src/shared/growing-byte-buffer.test.ts | 14 + src/shared/growing-byte-buffer.ts | 4 + src/shared/text-search.ts | 2 + src/shared/worktree/submodule-removal.test.ts | 39 --- src/shared/worktree/submodule-removal.ts | 12 - src/shared/wsl-paths.ts | 17 + 198 files changed, 6782 insertions(+), 2423 deletions(-) create mode 100644 src/main/git/git-network-safety-real-git.test.ts create mode 100644 src/main/git/repo-detection-batching.test.ts create mode 100644 src/main/git/runner-buffer-cancellation.test.ts create mode 100644 src/main/git/runner-network-ssh-policy.test.ts create mode 100644 src/main/git/runner-wsl-network-ssh-policy.test.ts create mode 100644 src/main/git/source-control/blob-absence-real-git.test.ts create mode 100644 src/main/git/source-control/bulk-pathspec-stdin.test.ts create mode 100644 src/main/git/source-control/staging-discard-index-safety.test.ts create mode 100644 src/main/git/worktree-branch-removal-host.test.ts create mode 100644 src/main/git/worktree-safety-real-git.test.ts create mode 100644 src/relay/git-command-admission.test.ts create mode 100644 src/relay/git-diff-cancellation.test.ts create mode 100644 src/relay/git-handler-clone-progress-capture.test.ts create mode 100644 src/relay/git-handler-commit-metadata.test.ts create mode 100644 src/relay/git-handler-diff-blobs.test.ts create mode 100644 src/relay/git-handler-diff-retry.test.ts create mode 100644 src/renderer/src/components/right-sidebar/source-control-discard-localization.test.ts create mode 100644 src/renderer/src/components/right-sidebar/source-control/commit/rename-mutation-paths.test.ts create mode 100644 src/shared/child-process/run-process-bytes.test.ts rename src/{main/git/command-runner => shared}/git-admission-candidate-heap.ts (100%) create mode 100644 src/shared/git-admission-node-compatibility.test.ts create mode 100644 src/shared/git-admission-scheduler.ts rename src/{main/git/command-runner => shared}/git-admission-state.ts (90%) rename src/{main/git/command-runner => shared}/git-admission-waiter-queue.ts (98%) create mode 100644 src/shared/git-blob-absence.test.ts create mode 100644 src/shared/git-blob-absence.ts create mode 100644 src/shared/git-change-list.test.ts create mode 100644 src/shared/git-change-list.ts create mode 100644 src/shared/git-command-classification.ts create mode 100644 src/shared/git-command-timeout.ts create mode 100644 src/shared/git-common-directory.ts create mode 100644 src/shared/git-default-base-ref.ts create mode 100644 src/shared/git-pathspec-stdin.ts create mode 100644 src/shared/git-resolution-binary-compatibility.test-cases.ts create mode 100644 src/shared/git-resolution-config-snapshot.test.ts create mode 100644 src/shared/git-ssh-policy-env.test.ts create mode 100644 src/shared/git-ssh-policy-env.ts create mode 100644 src/shared/git-status-read-lease-expiry.test.ts create mode 100644 src/shared/git-worktree-admin.test.ts create mode 100644 src/shared/git-worktree-admin.ts delete mode 100644 src/shared/worktree/submodule-removal.test.ts delete mode 100644 src/shared/worktree/submodule-removal.ts diff --git a/.github/actions/prepare-git-compatibility/action.yml b/.github/actions/prepare-git-compatibility/action.yml index 058d31a064c..96aa4ef9e15 100644 --- a/.github/actions/prepare-git-compatibility/action.yml +++ b/.github/actions/prepare-git-compatibility/action.yml @@ -10,7 +10,7 @@ runs: uses: actions/cache@v5 with: path: ~/.cache/orca-git-compat/git-2.25.5 - key: git-compat-baseline-${{ runner.os }}-${{ runner.arch }}-2.25.5 + key: git-compat-baseline-${{ runner.os }}-${{ runner.arch }}-2.25.5-submodule # Finish the CPU-heavy build before any timed compatibility lanes start. - name: Build the baseline Git binary @@ -18,7 +18,9 @@ runs: run: | archive="$RUNNER_TEMP/git-2.25.5.tar.gz" source="$HOME/.cache/orca-git-compat/git-2.25.5" - if [ -x "$source/git" ]; then + if [ -x "$source/git" ] && [ -x "$source/git-submodule" ] \ + && [ -f "$source/git-sh-setup" ] && [ -f "$source/git-sh-i18n" ] \ + && [ -f "$source/git-parse-remote" ] && [ -x "$source/git-sh-i18n--envsubst" ]; then exit 0 fi curl -fsSL https://www.kernel.org/pub/software/scm/git/git-2.25.5.tar.gz -o "$archive" @@ -27,6 +29,7 @@ runs: mkdir -p "$source" tar -xzf "$archive" -C "$source" --strip-components=1 make -C "$source" -j"$(nproc)" \ - NO_GETTEXT=YesPlease NO_TCLTK=YesPlease NO_PYTHON=YesPlease git + NO_GETTEXT=YesPlease NO_TCLTK=YesPlease NO_PYTHON=YesPlease \ + git git-submodule git-sh-setup git-sh-i18n git-parse-remote git-sh-i18n--envsubst # Object files are no longer needed after linking the cached binary. find "$source" -name '*.o' -delete diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml index 9144c36d43a..f17c7e8f1d7 100644 --- a/.github/workflows/mobile.yml +++ b/.github/workflows/mobile.yml @@ -114,12 +114,14 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - # Both compilers are read-only; finish them before starting the test workers. + # pnpm entrypoints can auto-install; finish each before the next mutates node_modules. - name: Typecheck id: production-types background: true run: pnpm typecheck + - wait: production-types + # Why a ratchet and not the raw typecheck: mobile/tsconfig.json excludes test files, so until # tsconfig.test.json existed nothing checked them, and at introduction 127 of the 632 had # drifted. This fails when a test file that checks today stops checking, when a test leaves @@ -127,8 +129,6 @@ jobs: - name: Typecheck tests (ratchet) run: pnpm run check:tests-typecheck - - wait: production-types - # This includes the bridged replay of the whole recording corpus, which used to be a second # step of its own behind RPC_FOUNDATION_BRIDGE=1. A gate nobody can forget to set is the point: # it fails when a divergence class grows, when a divergence lands in no class at all, or when diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 32a167cf071..290d4553a51 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -429,9 +429,11 @@ jobs: pids=() ( ORCA_GIT_COMPAT_BINARY="$HOME/.cache/orca-git-compat/git-2.25.5/git" \ + GIT_EXEC_PATH="$HOME/.cache/orca-git-compat/git-2.25.5" \ ORCA_GIT_COMPAT_VERSION="2.25.5" \ pnpm exec vitest run --config config/vitest.config.ts \ - src/shared/git-binary-compatibility.test.ts + src/shared/git-binary-compatibility.test.ts \ + src/main/git/worktree-safety-real-git.test.ts ) & pids+=("$!") @@ -441,7 +443,8 @@ jobs: version="${spec#*|}" ORCA_GIT_COMPAT_IMAGE="$image" ORCA_GIT_COMPAT_VERSION="$version" \ pnpm exec vitest run --config config/vitest.config.ts \ - src/shared/git-binary-compatibility.test.ts + src/shared/git-binary-compatibility.test.ts \ + src/main/git/worktree-safety-real-git.test.ts ) & pids+=("$!") done diff --git a/config/scripts/ci-background-step-barriers.test.mjs b/config/scripts/ci-background-step-barriers.test.mjs index ba4de163f2d..329e6c0bbce 100644 --- a/config/scripts/ci-background-step-barriers.test.mjs +++ b/config/scripts/ci-background-step-barriers.test.mjs @@ -97,13 +97,13 @@ describe('CI background step barriers', () => { expect(steps.findIndex((step) => step.id === 'changed-code-quality')).toBeGreaterThan(install) }) - it('finishes both mobile typechecks before allocating test workers', () => { + it('serializes mobile pnpm entrypoints before allocating test workers', () => { const steps = mobile.jobs.verify.steps assertJoinedBefore(steps, 'production-types', (step) => step.name === 'Test') const ratchet = steps.findIndex((step) => step.name === 'Typecheck tests (ratchet)') const join = steps.findIndex((step) => step.wait === 'production-types') expect(steps[ratchet].background).toBeUndefined() - expect(ratchet).toBeLessThan(join) + expect(ratchet).toBeGreaterThan(join) }) it('waits for WebKit and the bundle before any browser tests', () => { diff --git a/config/scripts/git-binary-compatibility-workflow.test.mjs b/config/scripts/git-binary-compatibility-workflow.test.mjs index c03c7973f6a..6acd6c4df50 100644 --- a/config/scripts/git-binary-compatibility-workflow.test.mjs +++ b/config/scripts/git-binary-compatibility-workflow.test.mjs @@ -16,10 +16,12 @@ describe('Git binary compatibility PR gate', () => { const run = stepNamed('Verify Git binary compatibility matrix')?.run expect(run).toContain('ORCA_GIT_COMPAT_BINARY="$HOME/.cache/orca-git-compat/git-2.25.5/git"') + expect(run).toContain('GIT_EXEC_PATH="$HOME/.cache/orca-git-compat/git-2.25.5"') expect(run).toContain('alpine/git:edge-2.38.1|2.38.1') expect(run).toContain('alpine/git:v2.49.1|2.49.1') expect(run).toContain('ORCA_GIT_COMPAT_IMAGE="$image"') expect(run).toContain('src/shared/git-binary-compatibility.test.ts') + expect(run).toContain('src/main/git/worktree-safety-real-git.test.ts') expect(run).toContain('pids+=("$!")') expect(run).toContain('wait "$pid" || status=1') }) @@ -30,10 +32,17 @@ describe('Git binary compatibility PR gate', () => { expect(run).toContain('git-2.25.5.tar.gz') // Why asserted: the sha256 check only runs on the build path, so a cached binary // must come from a key that pins the same version the tarball line declares. - expect(run).toContain('if [ -x "$source/git" ]; then') + expect(run).toContain('[ -x "$source/git" ] && [ -x "$source/git-submodule" ]') + expect(run).toContain('[ -f "$source/git-sh-setup" ] && [ -f "$source/git-sh-i18n" ]') + expect(run).toContain( + '[ -f "$source/git-parse-remote" ] && [ -x "$source/git-sh-i18n--envsubst" ]' + ) expect(run).toContain('41662c52fc16fec4963bfc41075e71f8ead6b5e386797eb6f9a1111ff95a8ddf') expect(run).toContain('-j"$(nproc)"') - expect(run).toContain('NO_GETTEXT=YesPlease NO_TCLTK=YesPlease NO_PYTHON=YesPlease git') + expect(run).toContain('NO_GETTEXT=YesPlease NO_TCLTK=YesPlease NO_PYTHON=YesPlease') + expect(run).toContain( + 'git git-submodule git-sh-setup git-sh-i18n git-parse-remote git-sh-i18n--envsubst' + ) expect(run).toContain('sha256sum --check') expect(run).toContain('find "$source" -name \'*.o\' -delete') // The cached path and the build path must be the same directory or the guard @@ -61,7 +70,7 @@ describe('Git binary compatibility PR gate', () => { expect(steps[matrixIndex].run).not.toContain('make -C') expect(baselineSteps[cacheIndex].with.path).toBe(BASELINE_DIR) expect(baselineSteps[cacheIndex].with.key).toBe( - 'git-compat-baseline-${{ runner.os }}-${{ runner.arch }}-2.25.5' + 'git-compat-baseline-${{ runner.os }}-${{ runner.arch }}-2.25.5-submodule' ) }) diff --git a/src/main/azure-devops/client.test.ts b/src/main/azure-devops/client.test.ts index 81761bba12f..679074df53f 100644 --- a/src/main/azure-devops/client.test.ts +++ b/src/main/azure-devops/client.test.ts @@ -22,11 +22,8 @@ function primeGitExecWithDefaultBranch(defaultRef = 'refs/remotes/origin/main'): if (args[0] === 'remote') { return { stdout: 'https://dev.azure.com/acme/Project/_git/repo\n', stderr: '' } } - if (args[0] === 'symbolic-ref' && args.includes('refs/remotes/origin/HEAD')) { - return { stdout: `${defaultRef}\n`, stderr: '' } - } - if (args[0] === 'rev-parse' && args[1] === '--verify' && args.includes(defaultRef)) { - return { stdout: 'default-oid\n', stderr: '' } + if (args[0] === 'for-each-ref' && args.includes('--format=%(refname)%00%(symref)')) { + return { stdout: `refs/remotes/origin/HEAD\0${defaultRef}\n`, stderr: '' } } throw new Error(`unexpected git call: ${args.join(' ')}`) }) diff --git a/src/main/bitbucket/client.test.ts b/src/main/bitbucket/client.test.ts index c3fc53de289..943e74b70b2 100644 --- a/src/main/bitbucket/client.test.ts +++ b/src/main/bitbucket/client.test.ts @@ -23,11 +23,8 @@ function primeGitExecWithDefaultBranch(defaultRef = 'refs/remotes/origin/main'): if (args[0] === 'remote') { return { stdout: 'git@bitbucket.org:team/repo.git\n', stderr: '' } } - if (args[0] === 'symbolic-ref' && args.includes('refs/remotes/origin/HEAD')) { - return { stdout: `${defaultRef}\n`, stderr: '' } - } - if (args[0] === 'rev-parse' && args[1] === '--verify' && args.includes(defaultRef)) { - return { stdout: 'default-oid\n', stderr: '' } + if (args[0] === 'for-each-ref' && args.includes('--format=%(refname)%00%(symref)')) { + return { stdout: `refs/remotes/origin/HEAD\0${defaultRef}\n`, stderr: '' } } throw new Error(`unexpected git call: ${args.join(' ')}`) }) diff --git a/src/main/git/command-runner/git-command-timeout.test.ts b/src/main/git/command-runner/git-command-timeout.test.ts index 30b5f93d64a..f5361532176 100644 --- a/src/main/git/command-runner/git-command-timeout.test.ts +++ b/src/main/git/command-runner/git-command-timeout.test.ts @@ -5,6 +5,7 @@ describe('gitCommandTimeoutMs', () => { it.each([ [['status', '--porcelain=v2'], 120_000], [['show', 'HEAD:file'], 120_000], + [['diff-tree', '--root', '-r', 'HEAD'], 120_000], [['fetch', 'origin'], undefined], [['checkout', 'main'], undefined], [['unknown'], undefined] diff --git a/src/main/git/command-runner/git-command-timeout.ts b/src/main/git/command-runner/git-command-timeout.ts index da8acd61bc9..0a3c8db48f1 100644 --- a/src/main/git/command-runner/git-command-timeout.ts +++ b/src/main/git/command-runner/git-command-timeout.ts @@ -1,23 +1 @@ -import { classifyGitCommand } from '../wsl-direct-git-read-commands' - -export const GIT_READ_TIMEOUT_MS = 120_000 - -export class GitCommandTimeoutError extends Error { - readonly timeoutMs: number - - constructor(timeoutMs: number) { - super('git timed out.') - this.name = 'GitCommandTimeoutError' - this.timeoutMs = timeoutMs - } -} - -export function gitCommandTimeoutMs( - args: readonly string[], - explicitTimeoutMs: number | undefined, - defaultReadTimeoutMs = GIT_READ_TIMEOUT_MS -): number | undefined { - return ( - explicitTimeoutMs ?? (classifyGitCommand(args) === 'read' ? defaultReadTimeoutMs : undefined) - ) -} +export * from '../../../shared/git-command-timeout' diff --git a/src/main/git/command-runner/git-exec-admission-lifetime.test.ts b/src/main/git/command-runner/git-exec-admission-lifetime.test.ts index 3b4893b35ce..86e1c6114f9 100644 --- a/src/main/git/command-runner/git-exec-admission-lifetime.test.ts +++ b/src/main/git/command-runner/git-exec-admission-lifetime.test.ts @@ -67,6 +67,15 @@ function mockChild(pid: number | undefined = 1234): ChildProcess { return child as unknown as ChildProcess } +function mockMissingSshConfig(callback: ExecCallback): ChildProcess { + const child = mockChild() + queueMicrotask(() => { + callback(Object.assign(new Error('No SSH configuration'), { code: 1 }), '', '') + child.emit('close', 1, null) + }) + return child +} + async function settleAdmissionGrant(): Promise { await vi.advanceTimersByTimeAsync(0) } @@ -151,18 +160,21 @@ describe('git exec admission lifetime', () => { const children = [mockChild(1001), mockChild(1002)] const callbacks: ExecCallback[] = [] execFileMock.mockImplementation( - (_command: string, _args: string[], _options: unknown, callback: ExecCallback) => { + (_command: string, args: string[], _options: unknown, callback: ExecCallback) => { + if (args[0] === 'config') { + return mockMissingSshConfig(callback) + } callbacks.push(callback) return children[callbacks.length - 1] } ) const first = gitExecFileAsync(['fetch', 'origin'], { cwd: '/same-repo' }) - await vi.waitFor(() => expect(execFileMock).toHaveBeenCalledOnce()) + await vi.waitFor(() => expect(callbacks).toHaveLength(1)) const second = gitExecFileAsync(['fetch', 'origin'], { cwd: '/same-repo' }) await Promise.resolve() - expect(execFileMock).toHaveBeenCalledOnce() + expect(execFileMock).toHaveBeenCalledTimes(2) expect(_gitAdmissionSnapshotForTests()).toMatchObject({ queued: 0, budgets: { network: { baseUsed: 1, headroomUsed: 0 } } @@ -171,7 +183,8 @@ describe('git exec admission lifetime', () => { callbacks[0]?.(null, '', '') children[0].emit('close', 0, null) await expect(first).resolves.toEqual({ stdout: '', stderr: '' }) - await vi.waitFor(() => expect(execFileMock).toHaveBeenCalledTimes(2)) + await vi.waitFor(() => expect(callbacks).toHaveLength(2)) + expect(execFileMock).toHaveBeenCalledTimes(4) callbacks[1]?.(null, '', '') children[1].emit('close', 0, null) await expect(second).resolves.toEqual({ stdout: '', stderr: '' }) @@ -300,6 +313,9 @@ describe('git exec admission lifetime', () => { const callbacks = new Map() execFileMock.mockImplementation( (_command: string, args: string[], _options: unknown, callback: ExecCallback) => { + if (args[0] === 'config') { + return mockMissingSshConfig(callback) + } const label = args[1] ?? '' const child = mockChild() children.set(label, child) diff --git a/src/main/git/command-runner/git-exec-file.ts b/src/main/git/command-runner/git-exec-file.ts index 13027d395b2..f581c807211 100644 --- a/src/main/git/command-runner/git-exec-file.ts +++ b/src/main/git/command-runner/git-exec-file.ts @@ -26,6 +26,7 @@ import { buildNetworkSshPolicyEnv } from './git-ssh-policy-env' import { nonInteractiveGitEnv, untranslatedGitOutputEnv } from './git-process-env' import { acquireGitAdmission } from './git-subprocess-admission' import { GitCommandTimeoutError, gitCommandTimeoutMs } from './git-command-timeout' +import { classifyGitCommand } from '../../../shared/git-command-classification' /** * Async git command execution. Drop-in replacement for @@ -62,9 +63,10 @@ async function gitExecFileAsyncUnlocked( false, effectiveOptions.captureWslLoginShellOutput ) - const policy = effectiveOptions.useConfiguredSshCommandForNetwork - ? await buildNetworkSshPolicyEnv(effectiveOptions) - : { env: nonInteractiveGitEnv(effectiveOptions.env), mode: 'default' as const } + const policy = + effectiveOptions.useConfiguredSshCommandForNetwork || classifyGitCommand(args) === 'network' + ? await buildNetworkSshPolicyEnv(effectiveOptions, args) + : { env: nonInteractiveGitEnv(effectiveOptions.env), mode: 'default' as const } const grant = options.admissionExempt ? { queueWaitMs: 0, release: () => {} } : await acquireGitAdmission({ @@ -194,11 +196,14 @@ export async function gitExecFileAsyncBuffer( wslDistro?: string preferWslDirectGit?: boolean admissionTier?: GitAdmissionTier + signal?: AbortSignal } ): Promise<{ stdout: Buffer }> { return withGitSpan({ args, cwd: options.cwd }, async (span) => { if (isWslLinkedWorktreeGitRoutingCandidate(options.cwd, options.wslDistro)) { - await prepareWslLinkedWorktreeGitRouting(options.cwd, options.wslDistro) + await prepareWslLinkedWorktreeGitRouting(options.cwd, options.wslDistro, { + signal: options.signal + }) } const readEnvironmentReady = pendingWslDirectGitReadEnvironment(args, options) if (readEnvironmentReady) { @@ -208,7 +213,7 @@ export async function gitExecFileAsyncBuffer( // still matters for the login-shell fallback: these are raw blob bytes going // straight to the diff/blob viewer, where a banner becomes file content. let resolved = resolveGitCommand(args, options, false, true) - const environmentReady = prepareWindowsHostGitEnvironment(resolved, undefined) + const environmentReady = prepareWindowsHostGitEnvironment(resolved, undefined, options.signal) if (environmentReady) { await environmentReady } @@ -217,7 +222,8 @@ export async function gitExecFileAsyncBuffer( args, cwd: options.cwd, wslDistro: options.wslDistro, - tier: options.admissionTier + tier: options.admissionTier, + signal: options.signal }) span?.setAttribute('git.queue_wait_ms', grant.queueWaitMs) const timeoutMs = gitCommandTimeoutMs(args, options.timeout, options.timeoutMsForTest) @@ -227,9 +233,10 @@ export async function gitExecFileAsyncBuffer( termination = new Promise((resolve) => { reportTerminated = resolve }) - const { stdout } = (await execFileCapture(resolved.binary, resolved.args, { + const { stdout } = await execFileCapture(resolved.binary, resolved.args, { cwd: resolved.cwd, encoding: 'buffer', + signal: options.signal, maxBuffer: options.maxBuffer, timeout: timeoutMs, env: untranslatedGitOutputEnv(options.env), @@ -238,7 +245,10 @@ export async function gitExecFileAsyncBuffer( ...(timeoutMs === undefined ? {} : { createTimeoutError: () => new GitCommandTimeoutError(timeoutMs) }) - })) as { stdout: Buffer } + }) + if (!Buffer.isBuffer(stdout)) { + throw new Error('Git buffer capture returned text instead of bytes') + } return { stdout: readCapturedGitBuffer(stdout, resolved) } } finally { if (termination) { diff --git a/src/main/git/command-runner/git-process-env.ts b/src/main/git/command-runner/git-process-env.ts index 9064f5ed900..5e5bba423ad 100644 --- a/src/main/git/command-runner/git-process-env.ts +++ b/src/main/git/command-runner/git-process-env.ts @@ -68,7 +68,7 @@ export function nonInteractiveGitEnv( // UTF-16LE (#9010), which is how a dead distro reached telemetry as an error with no text. next.WSL_UTF8 = '1' } - if (!next.GIT_SSH_COMMAND) { + if (!next.GIT_SSH_COMMAND && !next.GIT_SSH) { 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. diff --git a/src/main/git/command-runner/git-spawn.ts b/src/main/git/command-runner/git-spawn.ts index 47ccb501019..53de3d7615f 100644 --- a/src/main/git/command-runner/git-spawn.ts +++ b/src/main/git/command-runner/git-spawn.ts @@ -7,6 +7,8 @@ import { untranslatedGitOutputEnv } from './git-process-env' import { prepareWindowsHostGitEnvironment } from './windows-host-git-environment' import type { GitAdmissionTier } from './git-exec-options' import { acquireGitAdmission } from './git-subprocess-admission' +import { classifyGitCommand } from '../../../shared/git-command-classification' +import { buildNetworkSshPolicyEnv } from './git-ssh-policy-env' /** * Spawn a git child process. Drop-in replacement for @@ -35,7 +37,14 @@ export async function gitSpawnAfterWindowsEnvironmentReady( if (options.signal?.aborted) { throw createAbortError() } - return withGitAdmission(args, env === options.env ? options : { ...options, env }) + const policyEnv = + classifyGitCommand(args) === 'network' + ? (await buildNetworkSshPolicyEnv({ ...options, env }, args)).env + : env + return withGitAdmission( + args, + policyEnv === options.env ? options : { ...options, env: policyEnv } + ) } export async function withGitAdmission( diff --git a/src/main/git/command-runner/git-ssh-policy-env.ts b/src/main/git/command-runner/git-ssh-policy-env.ts index a5d8637bf9e..dc863eaed76 100644 --- a/src/main/git/command-runner/git-ssh-policy-env.ts +++ b/src/main/git/command-runner/git-ssh-policy-env.ts @@ -1,138 +1,39 @@ import { addWslEnvKeys } from '../../wsl-env' -import { quotePosixShell } from '../../../shared/wsl-login-shell-command' +import { + buildGitSshPolicyEnv, + GIT_SSH_CONFIG_ARGS, + parseGitSshConfig, + type GitSshPolicyMode +} from '../../../shared/git-ssh-policy-env' +import { findGitSubcommandIndex } from '../../../shared/git-command-classification' import { execFileCapture } from './exec-file-capture' import { resolveGitCommand } from './git-command-resolution' import { DEFAULT_GIT_MAX_BUFFER, type GitExecOptions } from './git-exec-options' import { promptGuardGitEnv } from './git-process-env' import { acquireGitAdmission } from './git-subprocess-admission' -export type GitSshPolicyMode = - | 'default' - | 'explicit-env' - | 'fallback' - | 'configured-openssh' - | 'configured-wrapper-passthrough' +export type { GitSshPolicyMode } from '../../../shared/git-ssh-policy-env' const CORE_SSH_COMMAND_PROBE_TIMEOUT_MS = 2500 +// Cold WSL starts and login rc files need the environment probe's startup budget. +const WSL_CORE_SSH_COMMAND_PROBE_TIMEOUT_MS = 10_000 -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<{ +export async function buildNetworkSshPolicyEnv( + options: GitExecOptions, + args: readonly string[] = [] +): Promise<{ env: NodeJS.ProcessEnv mode: GitSshPolicyMode }> { const promptEnv = promptGuardGitEnv(options.env) - if (promptEnv.GIT_SSH_COMMAND) { + if (promptEnv.GIT_SSH_COMMAND || promptEnv.GIT_SSH) { 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) - const probeArgs = ['config', '--get', 'core.sshCommand'] + // A login-shell banner must not become a configured SSH command. + const subcommandIndex = findGitSubcommandIndex(args) + const probeArgs = [...args.slice(0, Math.max(0, subcommandIndex)), ...GIT_SSH_CONFIG_ARGS] + const resolved = resolveGitCommand(probeArgs, options, true, true) const grant = await acquireGitAdmission({ args: probeArgs, cwd: options.cwd, @@ -144,43 +45,33 @@ export async function buildNetworkSshPolicyEnv(options: GitExecOptions): Promise const terminated = new Promise((resolve) => { reportTerminated = resolve }) - let configuredCommand = '' + let configured = parseGitSshConfig('') 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, + timeout: Math.min( + options.timeout && options.timeout > 0 ? options.timeout : Infinity, + resolved.wsl ? WSL_CORE_SSH_COMMAND_PROBE_TIMEOUT_MS : CORE_SSH_COMMAND_PROBE_TIMEOUT_MS + ), env: promptEnv, signal: options.signal, onChildTerminated: reportTerminated }) const payload = resolved.captured?.readStdout(String(stdout)) ?? String(stdout) - configuredCommand = payload.trim() - } catch { - configuredCommand = '' + configured = parseGitSshConfig(payload) + } catch (error) { + if (!error || typeof error !== 'object' || !('code' in error) || error.code !== 1) { + throw error + } } finally { void terminated.then(grant.release) } - 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 policy = buildGitSshPolicyEnv(promptEnv, configured.command, configured.variant) + if (resolved.wsl && policy.env.GIT_SSH_COMMAND && policy.env !== promptEnv) { + addWslEnvKeys(policy.env, ['GIT_SSH_COMMAND']) } - - 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' } + return policy } diff --git a/src/main/git/command-runner/git-subprocess-admission.test.ts b/src/main/git/command-runner/git-subprocess-admission.test.ts index 1ad55a05135..537458a07dd 100644 --- a/src/main/git/command-runner/git-subprocess-admission.test.ts +++ b/src/main/git/command-runner/git-subprocess-admission.test.ts @@ -6,7 +6,7 @@ import { _resetGitAdmissionForTests, acquireGitAdmission } from './git-subprocess-admission' -import type { GitAdmissionEvent } from './git-admission-state' +import type { GitAdmissionEvent } from '../../../shared/git-admission-state' const local = (tier: 'interactive' | 'status' | 'background' = 'status') => ({ args: ['status'], diff --git a/src/main/git/command-runner/git-subprocess-admission.ts b/src/main/git/command-runner/git-subprocess-admission.ts index b5b744ea97c..13bdf04cf07 100644 --- a/src/main/git/command-runner/git-subprocess-admission.ts +++ b/src/main/git/command-runner/git-subprocess-admission.ts @@ -1,308 +1,10 @@ -import { uncRouteKey } from '../../providers/working-directory-validation' -import { classifyGitCommand } from '../wsl-direct-git-read-commands' -import { createAbortError } from './abort-error' -import { GitAdmissionWaiterQueue } from './git-admission-waiter-queue' import { - ADMISSION_TIER_VALUE, - AdmissionEventPublisher, - DEFAULT_ADMISSION_SCHEDULER_CONFIG, - type AdmissionBudget, - type AdmissionClass, - type AdmissionSchedulerConfig, - type AdmissionSlotKind, - type AdmissionWaiter, + GitAdmissionScheduler, type GitAdmissionGrant, type GitAdmissionRequest -} from './git-admission-state' +} from '../../../shared/git-admission-scheduler' import { resolveGitAdmissionTier } from './git-operation-executor' - -export type { - GitAdmissionEvent, - GitAdmissionGrant, - GitAdmissionRequest -} from './git-admission-state' -export { - GENERAL_CAP, - GENERAL_HEADROOM, - GIT_ADMISSION_AGING_MS, - MAX_GIT_CHILDREN, - NETWORK_CAP, - NETWORK_HEADROOM, - ROUTE_CAP, - ROUTE_HEADROOM -} from './git-admission-state' - -function routeKey(request: GitAdmissionRequest): string | null { - const distro = request.wslDistro?.trim().toLowerCase() - return distro ? `wsl:${distro}` : uncRouteKey(request.cwd) -} - -export class GitAdmissionScheduler { - private readonly config: AdmissionSchedulerConfig - private readonly budgets = new Map() - private readonly waiters = new GitAdmissionWaiterQueue() - private nextWaiterId = 0 - private readonly eventPublisher: AdmissionEventPublisher - - constructor(config: Partial = {}) { - this.config = { ...DEFAULT_ADMISSION_SCHEDULER_CONFIG, ...config } - this.eventPublisher = new AdmissionEventPublisher(this.config.onAdmissionEvent) - } - - acquire(request: GitAdmissionRequest): Promise { - if (request.signal?.aborted) { - return Promise.reject(createAbortError()) - } - const enqueuedAt = this.config.now() - const { admissionClass, route, budgetKeys } = this.resolveBudgets(request) - return new Promise((resolve, reject) => { - const waiter: AdmissionWaiter = { - id: this.nextWaiterId++, - args: request.args, - tier: request.tier ?? 'status', - admissionClass, - route, - enqueuedAt, - budgetKeys, - signal: request.signal, - state: 'queued', - resolve, - reject, - onAbort: () => this.abort(waiter) - } - this.waiters.enqueue(waiter) - this.refreshRouteEligibility(admissionClass, route) - request.signal?.addEventListener('abort', waiter.onAbort, { once: true }) - if (request.signal?.aborted) { - this.abort(waiter) - return - } - // Adding a blocked waiter cannot make an older waiter runnable. Avoid a - // queue scan for every arrival while the fixed-size budget is saturated. - if (this.slotKindFor(waiter)) { - this.drain(admissionClass) - } - }) - } - - snapshot(): { - queued: number - queuedWaiters: { id: number; args: readonly string[]; tier: AdmissionWaiter['tier'] }[] - budgets: Record - candidateCount: number - } { - const queuedWaiters = this.waiters.snapshot() - return { - queued: this.waiters.count, - queuedWaiters: queuedWaiters.map(({ id, args, tier }) => ({ id, args, tier })), - candidateCount: this.waiters.candidateCountForTests, - budgets: Object.fromEntries( - [...this.budgets].map(([key, budget]) => [ - key, - { baseUsed: budget.baseUsed, headroomUsed: budget.headroomUsed } - ]) - ) - } - } - - private resolveBudgets(request: GitAdmissionRequest): { - admissionClass: AdmissionClass - route: string | null - budgetKeys: readonly string[] - } { - const admissionClass = classifyGitCommand(request.args) === 'network' ? 'network' : 'general' - const route = routeKey(request) - const keys: string[] = [admissionClass] - if (route) { - keys.push(`route:${admissionClass}:${route}`) - } - for (const key of keys) { - this.ensureBudget(key) - } - return { admissionClass, route, budgetKeys: keys } - } - - private ensureBudget(key: string): AdmissionBudget { - let budget = this.budgets.get(key) - if (budget) { - return budget - } - const isRoute = key.startsWith('route:') - const isNetwork = key === 'network' - budget = { - baseCapacity: isRoute - ? this.config.routeCap - : isNetwork - ? this.config.networkCap - : this.config.generalCap, - headroomCapacity: isRoute - ? this.config.routeHeadroom - : isNetwork - ? this.config.networkHeadroom - : this.config.generalHeadroom, - baseUsed: 0, - headroomUsed: 0 - } - this.budgets.set(key, budget) - return budget - } - - private effectiveTier(waiter: AdmissionWaiter, now: number): number { - const promotions = Math.floor((now - waiter.enqueuedAt) / this.config.agingMs) - return Math.max(0, ADMISSION_TIER_VALUE[waiter.tier] - promotions) - } - - private fits(waiter: AdmissionWaiter, slotKind: AdmissionSlotKind): boolean { - return waiter.budgetKeys.every((key) => { - const budget = this.ensureBudget(key) - return slotKind === 'base' - ? budget.baseUsed < budget.baseCapacity - : budget.headroomUsed < budget.headroomCapacity - }) - } - - private slotKindFor(waiter: AdmissionWaiter): AdmissionSlotKind | null { - return this.fits(waiter, 'base') - ? 'base' - : waiter.tier === 'interactive' && this.fits(waiter, 'headroom') - ? 'headroom' - : null - } - - private drain(admissionClass: AdmissionClass): void { - while (true) { - const now = this.config.now() - const globalBudget = this.ensureBudget(admissionClass) - const selected = this.waiters.nextFitting( - admissionClass, - (waiter) => this.effectiveTier(waiter, now), - globalBudget.baseUsed < globalBudget.baseCapacity, - globalBudget.headroomUsed < globalBudget.headroomCapacity, - (waiter) => this.abort(waiter) - ) - if (!selected) { - return - } - this.grant(selected.waiter, selected.slotKind, now) - } - } - - private grant(waiter: AdmissionWaiter, slotKind: AdmissionSlotKind, now: number): void { - waiter.state = 'granted' - waiter.slotKind = slotKind - for (const key of waiter.budgetKeys) { - const budget = this.ensureBudget(key) - if (slotKind === 'base') { - budget.baseUsed += 1 - } else { - budget.headroomUsed += 1 - } - } - this.refreshRouteEligibility(waiter.admissionClass, waiter.route) - this.waiters.dequeue(waiter) - const queueWaitMs = Math.max(0, now - waiter.enqueuedAt) - this.publishEvent(waiter, slotKind, 'grant', queueWaitMs) - queueMicrotask(() => { - if (waiter.state !== 'granted') { - return - } - waiter.state = 'settled' - waiter.signal?.removeEventListener('abort', waiter.onAbort) - waiter.resolve({ - queueWaitMs, - release: this.releaseOnce(waiter, slotKind, queueWaitMs) - }) - }) - } - - private releaseOnce( - waiter: AdmissionWaiter, - slotKind: AdmissionSlotKind, - queueWaitMs: number - ): () => void { - let released = false - return () => { - if (released) { - return - } - released = true - for (const key of waiter.budgetKeys) { - const budget = this.ensureBudget(key) - if (slotKind === 'base') { - budget.baseUsed -= 1 - } else { - budget.headroomUsed -= 1 - } - } - this.refreshRouteEligibility(waiter.admissionClass, waiter.route) - this.publishEvent(waiter, slotKind, 'release', queueWaitMs) - this.pruneRouteBudgets(waiter.budgetKeys) - this.drain(waiter.admissionClass) - } - } - - private abort(waiter: AdmissionWaiter): void { - if (waiter.state === 'settled') { - return - } - if (waiter.state === 'granted' && waiter.slotKind) { - this.releaseOnce( - waiter, - waiter.slotKind, - Math.max(0, this.config.now() - waiter.enqueuedAt) - )() - } - const wasQueued = waiter.state === 'queued' - waiter.state = 'settled' - waiter.signal?.removeEventListener('abort', waiter.onAbort) - if (wasQueued) { - this.waiters.dequeue(waiter) - this.pruneRouteBudgets(waiter.budgetKeys) - } - waiter.reject(createAbortError()) - } - - private publishEvent( - waiter: AdmissionWaiter, - slotKind: AdmissionSlotKind, - phase: 'grant' | 'release', - queueWaitMs: number - ): void { - this.eventPublisher.publish({ - phase, - waiter, - slotKind, - queueWaitMs, - queued: this.waiters.count, - budgets: this.budgets - }) - } - - private pruneRouteBudgets(keys: readonly string[]): void { - for (const key of keys) { - const budget = this.budgets.get(key) - if ( - budget && - key.startsWith('route:') && - budget.baseUsed === 0 && - budget.headroomUsed === 0 && - !this.waiters.hasBudget(key) - ) { - this.budgets.delete(key) - } - } - } - - private refreshRouteEligibility(admissionClass: AdmissionClass, route: string | null): void { - const budget = route ? this.ensureBudget(`route:${admissionClass}:${route}`) : null - this.waiters.updateRouteEligibility( - admissionClass, - route, - !budget || budget.baseUsed < budget.baseCapacity, - !budget || budget.headroomUsed < budget.headroomCapacity - ) - } -} +export * from '../../../shared/git-admission-scheduler' let scheduler = new GitAdmissionScheduler() diff --git a/src/main/git/git-network-safety-real-git.test.ts b/src/main/git/git-network-safety-real-git.test.ts new file mode 100644 index 00000000000..7fd7dbb26f0 --- /dev/null +++ b/src/main/git/git-network-safety-real-git.test.ts @@ -0,0 +1,143 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' +import { quotePosixShell } from '../../shared/wsl-login-shell-command' +import { createGitHandlerRelay } from '../../relay/git-handler-test-harness' +import { gitExecFileAsync, gitSpawnAfterWindowsEnvironmentReady } from './runner' + +let root = '' +let repo = '' +let script = '' +let marker = '' +let env: NodeJS.ProcessEnv = {} + +async function git(args: string[]): Promise { + const result = await runProcess({ program: 'git', args, cwd: repo, env }) + if (result.code !== 0) { + throw new Error(result.stderr) + } + return result.stdout +} + +function sshCommand(identity: string): string { + return [process.execPath, script, marker, identity].map(quotePosixShell).join(' ') +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-git-network-safety-')) + repo = join(root, 'repo') + script = join(root, 'ssh wrapper.cjs') + marker = join(root, 'ssh calls.jsonl') + await mkdir(repo) + const globalConfig = join(root, 'global.gitconfig') + await writeFile(globalConfig, '') + env = { + ...process.env, + GIT_CONFIG_GLOBAL: globalConfig, + GIT_CONFIG_NOSYSTEM: '1', + GIT_SSH: undefined, + GIT_SSH_COMMAND: undefined, + GIT_SSH_VARIANT: undefined + } + await writeFile( + script, + "const fs = require('node:fs'); fs.appendFileSync(process.argv[2], JSON.stringify({ args: process.argv.slice(3), prompt: process.env.GIT_TERMINAL_PROMPT, askpass: process.env.SSH_ASKPASS }) + '\\n'); process.exit(1);\n" + ) + await git(['init', '-q']) + await git(['remote', 'add', 'origin', 'ssh://example.invalid/repository']) + vi.stubEnv('GIT_CONFIG_GLOBAL', globalConfig) + vi.stubEnv('GIT_CONFIG_NOSYSTEM', '1') + vi.stubEnv('GIT_SSH', undefined) + vi.stubEnv('GIT_SSH_COMMAND', undefined) + vi.stubEnv('GIT_SSH_VARIANT', undefined) +}) + +afterEach(async () => { + vi.unstubAllEnvs() + await rm(root, { recursive: true, force: true }) +}) + +async function expectIdentity(identity: string): Promise { + const calls = (await readFile(marker, 'utf8')) + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + expect(calls.length).toBeGreaterThan(0) + for (const call of calls) { + expect(call.args[0]).toBe(identity) + expect(call.prompt).toBe('0') + expect(call.askpass).toBe('') + } +} + +describe('network SSH configuration with real Git', () => { + it.each(['native', 'relay'] as const)( + '%s honors repository SSH wrappers on ordinary fetches', + async (host) => { + await git(['config', 'core.sshCommand', sshCommand('configured identity')]) + if (host === 'native') { + await expect(gitExecFileAsync(['fetch', 'origin'], { cwd: repo, env })).rejects.toThrow() + } else { + const { dispatcher, handler } = createGitHandlerRelay() + try { + await expect( + dispatcher.callRequest('git.fetch', { worktreePath: repo }) + ).rejects.toThrow() + } finally { + handler.dispose() + } + } + await expectIdentity('configured identity') + } + ) + + it('preserves command-line SSH configuration ahead of repository configuration', async () => { + await git(['config', 'core.sshCommand', sshCommand('repository')]) + await expect( + gitExecFileAsync( + ['-c', `core.sshCommand=${sshCommand('command-line')}`, 'ls-remote', 'origin'], + { cwd: repo, env } + ) + ).rejects.toThrow() + await expectIdentity('command-line') + }) + + it.each(['native', 'relay'] as const)('%s preserves an explicit SSH command', async (host) => { + await git(['config', 'core.sshCommand', sshCommand('repository')]) + const command = sshCommand('environment') + if (host === 'native') { + await expect( + gitExecFileAsync(['fetch', 'origin'], { + cwd: repo, + env: { ...env, GIT_SSH_COMMAND: command } + }) + ).rejects.toThrow() + } else { + vi.stubEnv('GIT_SSH_COMMAND', command) + const { dispatcher, handler } = createGitHandlerRelay() + try { + await expect(dispatcher.callRequest('git.fetch', { worktreePath: repo })).rejects.toThrow() + } finally { + handler.dispose() + } + } + await expectIdentity('environment') + }) + + it('honors configured global SSH wrappers for streaming clones from a folder', async () => { + await git(['config', '--global', 'core.sshCommand', sshCommand('clone identity')]) + const child = await gitSpawnAfterWindowsEnvironmentReady( + ['clone', 'ssh://example.invalid/repository', 'clone'], + { cwd: root, env, stdio: 'pipe' } + ) + child.stdout?.resume() + child.stderr?.resume() + await new Promise((resolve, reject) => { + child.once('error', reject) + child.once('close', () => resolve()) + }) + await expectIdentity('clone identity') + }) +}) diff --git a/src/main/git/remote.test.ts b/src/main/git/remote.test.ts index feb237eb18a..bd8578e8d02 100644 --- a/src/main/git/remote.test.ts +++ b/src/main/git/remote.test.ts @@ -41,27 +41,21 @@ describe('git remote operations', () => { if (args[0] === 'symbolic-ref') { return { stdout: 'review/pr-1738\n', stderr: '' } } - if (args[0] === 'config' && args.includes('branch.review/pr-1738.remote')) { - return { stdout: 'pr-prateek-orca\n', stderr: '' } - } - if (args[0] === 'config' && args.includes('branch.review/pr-1738.pushRemote')) { - return { stdout: 'pr-prateek-orca\n', stderr: '' } - } - if (args[0] === 'config' && args.includes('branch.review/pr-1738.merge')) { - return { stdout: 'refs/heads/prateek/fix-sidebar-agents-toggle\n', stderr: '' } - } - if (args[0] === 'config' && args.includes('branch.review/pr-1738.base')) { - throw new Error('missing branch base') + if (args[0] === 'config' && args[1] === '--list') { + return { + stdout: + 'branch.review/pr-1738.remote\npr-prateek-orca\0' + + 'branch.review/pr-1738.pushremote\npr-prateek-orca\0' + + 'branch.review/pr-1738.merge\nrefs/heads/prateek/fix-sidebar-agents-toggle\0', + stderr: '' + } } return { stdout: '', stderr: '' } }) await gitPush('/repo', false) - expect(gitExecFileAsyncMock).toHaveBeenCalledWith( - ['config', '--get', 'branch.review/pr-1738.remote'], - { cwd: '/repo' } - ) + expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['config', '--list', '-z'], { cwd: '/repo' }) expect(gitExecFileAsyncMock).toHaveBeenLastCalledWith( ['push', '--set-upstream', 'pr-prateek-orca', 'HEAD:prateek/fix-sidebar-agents-toggle'], { cwd: '/repo' } @@ -73,20 +67,15 @@ describe('git remote operations', () => { if (args[0] === 'symbolic-ref') { return { stdout: 'feature/fix\n', stderr: '' } } - if (args[0] === 'config' && args.includes('branch.feature/fix.remote')) { - return { stdout: 'origin\n', stderr: '' } - } - if (args[0] === 'config' && args.includes('branch.feature/fix.pushRemote')) { - throw new Error('missing pushRemote') - } - if (args[0] === 'config' && args.includes('remote.pushDefault')) { - return { stdout: 'fork\n', stderr: '' } - } - if (args[0] === 'config' && args.includes('branch.feature/fix.merge')) { - return { stdout: 'refs/heads/main\n', stderr: '' } - } - if (args[0] === 'config' && args.includes('branch.feature/fix.base')) { - return { stdout: 'refs/remotes/origin/main\n', stderr: '' } + if (args[0] === 'config' && args[1] === '--list') { + return { + stdout: + 'branch.feature/fix.remote\norigin\0' + + 'branch.feature/fix.merge\nrefs/heads/main\0' + + 'branch.feature/fix.base\nrefs/remotes/origin/main\0' + + 'remote.pushdefault\nfork\0', + stderr: '' + } } return { stdout: '', stderr: '' } }) @@ -108,17 +97,15 @@ describe('git remote operations', () => { if (args[0] === 'symbolic-ref') { return { stdout: 'review/pr-1\n', stderr: '' } } - if (args[0] === 'config' && args.includes('branch.review/pr-1.remote')) { - return { stdout: 'fork\n', stderr: '' } - } - if (args[0] === 'config' && args.includes('branch.review/pr-1.pushRemote')) { - return { stdout: 'fork\n', stderr: '' } - } - if (args[0] === 'config' && args.includes('branch.review/pr-1.merge')) { - return { stdout: 'refs/heads/main\n', stderr: '' } - } - if (args[0] === 'config' && args.includes('branch.review/pr-1.base')) { - return { stdout: 'refs/remotes/origin/main\n', stderr: '' } + if (args[0] === 'config' && args[1] === '--list') { + return { + stdout: + 'branch.review/pr-1.remote\nfork\0' + + 'branch.review/pr-1.pushremote\nfork\0' + + 'branch.review/pr-1.merge\nrefs/heads/main\0' + + 'branch.review/pr-1.base\nrefs/remotes/origin/main\0', + stderr: '' + } } return { stdout: '', stderr: '' } }) @@ -136,17 +123,14 @@ describe('git remote operations', () => { if (args[0] === 'symbolic-ref') { return { stdout: 'imp/chinese-translation\n', stderr: '' } } - if (args[0] === 'config' && args.includes('branch.imp/chinese-translation.pushRemote')) { - return { stdout: 'https://github.com/pynickle/orca.git\n', stderr: '' } - } - if (args[0] === 'config' && args.includes('remote.pushDefault')) { - throw new Error('missing pushDefault') - } - if (args[0] === 'config' && args.includes('branch.imp/chinese-translation.remote')) { - return { stdout: 'https://github.com/pynickle/orca.git\n', stderr: '' } - } - if (args[0] === 'config' && args.includes('branch.imp/chinese-translation.merge')) { - return { stdout: 'refs/heads/imp/chinese-translation\n', stderr: '' } + if (args[0] === 'config' && args[1] === '--list') { + return { + stdout: + 'branch.imp/chinese-translation.remote\nhttps://github.com/pynickle/orca.git\0' + + 'branch.imp/chinese-translation.pushremote\nhttps://github.com/pynickle/orca.git\0' + + 'branch.imp/chinese-translation.merge\nrefs/heads/imp/chinese-translation\0', + stderr: '' + } } if (args[0] === 'remote' && args[1] === 'get-url') { return { stdout: 'https://github.com/stablyai/orca.git\n', stderr: '' } @@ -175,17 +159,13 @@ describe('git remote operations', () => { if (args[0] === 'symbolic-ref') { return { stdout: 'imp/chinese-translation\n', stderr: '' } } - if (args[0] === 'config' && args.includes('branch.imp/chinese-translation.pushRemote')) { - throw new Error('missing pushRemote') - } - if (args[0] === 'config' && args.includes('remote.pushDefault')) { - throw new Error('missing pushDefault') - } - if (args[0] === 'config' && args.includes('branch.imp/chinese-translation.remote')) { - return { stdout: 'https://github.com/pynickle/orca.git\n', stderr: '' } - } - if (args[0] === 'config' && args.includes('branch.imp/chinese-translation.merge')) { - return { stdout: 'refs/heads/imp/chinese-translation\n', stderr: '' } + if (args[0] === 'config' && args[1] === '--list') { + return { + stdout: + 'branch.imp/chinese-translation.remote\nhttps://github.com/pynickle/orca.git\0' + + 'branch.imp/chinese-translation.merge\nrefs/heads/imp/chinese-translation\0', + stderr: '' + } } if (args[0] === 'remote' && args[1] === 'get-url' && args[2] === 'origin') { return { stdout: 'https://github.com/stablyai/orca.git\n', stderr: '' } @@ -233,14 +213,13 @@ describe('git remote operations', () => { if (args[0] === 'symbolic-ref') { return { stdout: 'imp/chinese-translation\n', stderr: '' } } - if (args[0] === 'config' && args.includes('branch.imp/chinese-translation.remote')) { - return { stdout: 'https://github.com/pynickle/orca.git\n', stderr: '' } - } - if (args[0] === 'config' && args.includes('branch.imp/chinese-translation.merge')) { - return { stdout: 'refs/heads/imp/chinese-translation\n', stderr: '' } - } - if (args[0] === 'config') { - throw new Error(`config key is not set: ${args.join(' ')}`) + if (args[0] === 'config' && args[1] === '--list') { + return { + stdout: + 'branch.imp/chinese-translation.remote\nhttps://github.com/pynickle/orca.git\0' + + 'branch.imp/chinese-translation.merge\nrefs/heads/imp/chinese-translation\0', + stderr: '' + } } if (args[0] === 'remote' && args[1] === '-v') { return { @@ -289,8 +268,10 @@ describe('git remote operations', () => { it('passes --force-with-lease when requested', async () => { gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: 'feature\n', stderr: '' }) - .mockResolvedValueOnce({ stdout: 'origin\n', stderr: '' }) - .mockResolvedValueOnce({ stdout: 'refs/heads/feature\n', stderr: '' }) + .mockResolvedValueOnce({ + stdout: 'branch.feature.remote\norigin\0branch.feature.merge\nrefs/heads/feature\0', + stderr: '' + }) .mockResolvedValueOnce({ stdout: '', stderr: '' }) await gitPush('/repo', false, undefined, { forceWithLease: true }) diff --git a/src/main/git/remove-worktree.test.ts b/src/main/git/remove-worktree.test.ts index d2246da56ad..f50514f3efb 100644 --- a/src/main/git/remove-worktree.test.ts +++ b/src/main/git/remove-worktree.test.ts @@ -432,7 +432,7 @@ branch refs/heads/main expect(getGitCalls()).toContain('git worktree remove --force /repo-feature') }) - it('force-retries removal when git refuses a clean worktree containing an initialised submodule', async () => { + it('preserves Git refusal even when parent status cannot reveal unpublished submodule commits', async () => { mockGitCommands({ 'git worktree list --porcelain': { stdout: `worktree /repo @@ -457,25 +457,14 @@ branch refs/heads/main 'git status --porcelain --untracked-files=all': { stdout: '' } }) - await removeWorktree('/repo', '/repo-feature') - - const calls = getGitCalls() - expectGitCallOrder( - calls, - 'git worktree remove /repo-feature', - 'git worktree remove --force /repo-feature' + await expect(removeWorktree('/repo', '/repo-feature')).rejects.toThrow( + 'git worktree remove failed' ) - // The re-proof of cleanliness between the refusal and the forced retry. - expect(calls.lastIndexOf('git status --porcelain --untracked-files=all')).toBeGreaterThan( - calls.indexOf('git worktree remove /repo-feature') - ) - expect(calls.lastIndexOf('git status --porcelain --untracked-files=all')).toBeLessThan( - calls.indexOf('git worktree remove --force /repo-feature') - ) - expect(calls).toContain('git branch -d -- feature/test') + expect(getGitCalls()).not.toContain('git worktree remove --force /repo-feature') + expect(getGitCalls()).not.toContain('git branch -d -- feature/test') }) - it('surfaces uncommitted changes instead of force-removing a dirty submodule worktree', async () => { + it('preserves Git refusal for a dirty submodule worktree', async () => { mockGitCommands({ 'git worktree list --porcelain': { stdout: `worktree /repo @@ -495,7 +484,7 @@ branch refs/heads/feature/test }) await expect(removeWorktree('/repo', '/repo-feature')).rejects.toThrow( - 'Worktree has uncommitted or untracked changes.' + 'git worktree remove failed' ) expect(getGitCalls()).not.toContain('git worktree remove --force /repo-feature') }) diff --git a/src/main/git/repo-branch-conflict-batched-probe.test.ts b/src/main/git/repo-branch-conflict-batched-probe.test.ts index e6e672c65a2..31aa0dc10f3 100644 --- a/src/main/git/repo-branch-conflict-batched-probe.test.ts +++ b/src/main/git/repo-branch-conflict-batched-probe.test.ts @@ -28,6 +28,9 @@ function installLoginShellRunner(): { argv: string[][] } { const argv: string[][] = [] gitExecFileAsyncMock.mockImplementation(async (args: string[], options: GitExecOptions = {}) => { argv.push(args) + if (args[0] === 'for-each-ref') { + return { stdout: '', stderr: '' } + } if (args[0] === 'rev-parse') { throw new Error('local branch is absent') } @@ -82,6 +85,9 @@ describe('getBranchConflictKind batched remote probe', () => { it('still falls back to per-ref probes when the batch itself fails', async () => { gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'for-each-ref') { + return { stdout: '', stderr: '' } + } if (args[0] === 'rev-parse') { throw new Error('local branch is absent') } diff --git a/src/main/git/repo-branch-conflict.test.ts b/src/main/git/repo-branch-conflict.test.ts index 82bd1e65c9d..243ce7ab2ba 100644 --- a/src/main/git/repo-branch-conflict.test.ts +++ b/src/main/git/repo-branch-conflict.test.ts @@ -7,6 +7,9 @@ describe('getBranchConflictKindViaExec', () => { const calls: string[][] = [] const exec = async (argv: string[]): Promise<{ stdout: string }> => { calls.push(argv) + if (argv[0] === 'for-each-ref') { + return { stdout: '' } + } if (argv[0] === 'rev-parse') { throw new Error('local branch is absent') } @@ -22,6 +25,7 @@ describe('getBranchConflictKindViaExec', () => { await expect(getBranchConflictKindViaExec(exec, 'feature/fix')).resolves.toBe('remote') expect(calls).toEqual([ ['rev-parse', '--verify', '--quiet', 'refs/heads/feature/fix'], + ['for-each-ref', '--format=%(refname)', 'refs/heads/'], ['remote'], ['show-ref', '--verify', '--quiet', '--', 'refs/remotes/foo/bar/feature/fix'], ['show-ref', '--verify', '--quiet', '--', 'refs/remotes/origin/feature/fix'] @@ -32,6 +36,9 @@ describe('getBranchConflictKindViaExec', () => { const calls: string[][] = [] const exec = async (argv: string[]): Promise<{ stdout: string }> => { calls.push(argv) + if (argv[0] === 'for-each-ref') { + return { stdout: '' } + } if (argv[0] === 'remote') { return { stdout: 'origin\n' } } @@ -43,6 +50,7 @@ describe('getBranchConflictKindViaExec', () => { ).resolves.toBeNull() expect(calls).toEqual([ ['rev-parse', '--verify', '--quiet', 'refs/heads/feature/fix'], + ['for-each-ref', '--format=%(refname)', 'refs/heads/'], ['remote'] ]) }) @@ -51,6 +59,9 @@ describe('getBranchConflictKindViaExec', () => { const calls: string[][] = [] const exec = async (argv: string[]): Promise<{ stdout: string }> => { calls.push(argv) + if (argv[0] === 'for-each-ref') { + return { stdout: '' } + } if (argv[0] === 'rev-parse') { throw new Error('local branch is absent') } @@ -77,6 +88,9 @@ describe('getBranchConflictKindViaExec', () => { const calls: string[][] = [] const exec = async (argv: string[]): Promise<{ stdout: string }> => { calls.push(argv) + if (argv[0] === 'for-each-ref') { + return { stdout: '' } + } if (argv[0] === 'rev-parse') { throw new Error('local branch is absent') } @@ -106,6 +120,9 @@ describe('getBranchConflictKindViaExec', () => { let activeProbes = 0 let maxActiveProbes = 0 const exec = async (argv: string[]): Promise<{ stdout: string }> => { + if (argv[0] === 'for-each-ref') { + return { stdout: '' } + } if (argv[0] === 'rev-parse') { throw new Error('local branch is absent') } @@ -146,6 +163,9 @@ describe('getBranchConflictKindViaExec batched remote probe', () => { function baseExec(calls: string[][]): (argv: string[]) => Promise<{ stdout: string }> { return async (argv) => { calls.push(argv) + if (argv[0] === 'for-each-ref') { + return { stdout: '' } + } if (argv[0] === 'rev-parse') { throw new Error('local branch is absent') } @@ -180,6 +200,7 @@ describe('getBranchConflictKindViaExec batched remote probe', () => { ).resolves.toBeNull() expect(calls).toEqual([ ['rev-parse', '--verify', '--quiet', 'refs/heads/feature'], + ['for-each-ref', '--format=%(refname)', 'refs/heads/'], ['remote'], ['cat-file', '--batch-check'] ]) @@ -208,6 +229,9 @@ describe('getBranchConflictKindViaExec batched remote probe', () => { const calls: string[][] = [] const exec = async (argv: string[]): Promise<{ stdout: string }> => { calls.push(argv) + if (argv[0] === 'for-each-ref') { + return { stdout: '' } + } if (argv[0] === 'rev-parse') { throw new Error('local branch is absent') } @@ -236,6 +260,9 @@ describe('getBranchConflictKindViaExec batched remote probe', () => { const calls: string[][] = [] const exec = async (argv: string[]): Promise<{ stdout: string }> => { calls.push(argv) + if (argv[0] === 'for-each-ref') { + return { stdout: '' } + } if (argv[0] === 'rev-parse') { throw new Error('local branch is absent') } @@ -263,6 +290,9 @@ describe('branch conflict with existing-branch adoption', () => { it('skips adoption and its commit probe for a proven missing local ref', async () => { const exec = vi.fn(async (argv: string[]) => { + if (argv[0] === 'for-each-ref') { + return { stdout: '' } + } if (argv[0] === 'rev-parse') { throw absent() } @@ -273,7 +303,7 @@ describe('branch conflict with existing-branch adoption', () => { getBranchConflictKindViaExec(exec, 'new', undefined, {}, undefined, adopt) ).resolves.toBeNull() expect(adopt).not.toHaveBeenCalled() - expect(exec).toHaveBeenCalledTimes(2) + expect(exec).toHaveBeenCalledTimes(3) }) it('allows an existing branch without querying remote refs', async () => { @@ -316,9 +346,84 @@ describe('branch conflict with existing-branch adoption', () => { .mockResolvedValueOnce({ stdout: 'a'.repeat(40) }) .mockRejectedValueOnce(absent()) .mockResolvedValueOnce({ stdout: '' }) + .mockResolvedValueOnce({ stdout: '' }) await expect( getBranchConflictKindViaExec(exec, 'removed', undefined, {}, undefined, async () => false) ).resolves.toBeNull() - expect(exec).toHaveBeenCalledTimes(3) + expect(exec).toHaveBeenCalledTimes(4) + }) +}) + +describe('portable branch-name conflicts', () => { + function executor(refs = '') { + return vi.fn(async (argv: string[]) => { + if (argv[0] === 'rev-parse') { + throw Object.assign(new Error('missing'), { code: 1, stderr: '' }) + } + return { stdout: argv[0] === 'for-each-ref' ? refs : '' } + }) + } + + it.each([ + ['feature', 'Feature'], + ['Ä', 'ä'], + ['K', 'K'], + ['ß', 'ẞ'], + ['ẞ', 'ß'], + ['ς', 'Σ'], + ['ffi', 'FFI'], + ['𐐀', '𐐨'], + ['é', 'e\u0301'], + ['feature/Ä', 'FEATURE/ä'] + ])('rejects packed ref aliases %s → %s without a config probe', async (existing, candidate) => { + const exec = executor(`refs/heads/${existing}\n`) + const adopt = vi.fn(async () => false) + await expect( + getBranchConflictKindViaExec(exec, candidate, undefined, {}, undefined, adopt) + ).resolves.toBe('local') + expect(adopt).not.toHaveBeenCalled() + expect(exec.mock.calls.map(([argv]) => argv)).toEqual([ + ['rev-parse', '--verify', '--quiet', `refs/heads/${candidate}`], + ['for-each-ref', '--format=%(refname)', 'refs/heads/'] + ]) + }) + + it('does not confuse descendants or similarly named branches with aliases', async () => { + const exec = executor('refs/heads/feature/child\nrefs/heads/feature-other\n') + await expect(getBranchConflictKindViaExec(exec, 'Feature')).resolves.toBeNull() + }) + + it.each([ + Object.assign(new Error('SSH disconnected'), { code: 1, stderr: 'transport failed' }), + Object.assign(new Error('output exceeded cap'), { code: 'ENOBUFS' }) + ])('fails closed when the bounded host listing fails: %s', async (error) => { + const exec = executor() + exec.mockImplementationOnce(async () => { + throw Object.assign(new Error('missing'), { code: 1, stderr: '' }) + }) + exec.mockImplementationOnce(async () => { + throw error + }) + await expect(getBranchConflictKindViaExec(exec, 'Feature')).rejects.toThrow(error.message) + expect(exec.mock.calls.some(([argv]) => argv[0] === 'remote')).toBe(false) + }) + + it('fails closed on unexpected stdout instead of treating it as an empty listing', async () => { + const exec = executor('Welcome to the host\n') + await expect(getBranchConflictKindViaExec(exec, 'Feature')).rejects.toThrow( + 'Cannot verify branch-name case conflicts' + ) + }) + + it('forwards the host output and timeout bounds to the local branch listing', async () => { + const exec = executor() + await getBranchConflictKindViaExec(exec, 'new-feature', undefined, { + maxBuffer: 1024, + timeoutMs: 100 + }) + expect(exec).toHaveBeenCalledWith(['for-each-ref', '--format=%(refname)', 'refs/heads/'], { + maxBuffer: 1024, + timeoutMs: 100 + }) }) }) diff --git a/src/main/git/repo-branch-conflict.ts b/src/main/git/repo-branch-conflict.ts index f22fd97f99e..738d987dd9f 100644 --- a/src/main/git/repo-branch-conflict.ts +++ b/src/main/git/repo-branch-conflict.ts @@ -60,6 +60,31 @@ async function listRemoteNamesViaExec( } } +async function hasCaseFoldedLocalConflict( + exec: ExactRefProbeExec, + ref: string, + options: ExactRefProbeExecOptions +): Promise { + // Git's case-insensitive patterns miss Unicode aliases accepted by ref filesystems. + const { stdout } = await runGit( + exec, + ['for-each-ref', '--format=%(refname)', 'refs/heads/'], + options + ) + const foldRef = (name: string) => + name.normalize('NFD').toLowerCase().toUpperCase().normalize('NFD') + const foldedRef = foldRef(ref) + for (const existingRef of stdout.split(/\r?\n/).filter(Boolean)) { + if (!existingRef.startsWith('refs/heads/') || !isSafeGitRefName(existingRef)) { + throw new Error('Cannot verify branch-name case conflicts.') + } + if (foldRef(existingRef) === foldedRef) { + return true + } + } + return false +} + function buildRemoteBranchConflictRefs( remoteNames: readonly string[], branchName: string, @@ -129,6 +154,9 @@ export async function getBranchConflictKindViaExec( if (presence === 'present') { return 'local' } + if (await hasCaseFoldedLocalConflict(exec, localRef, probeOptions)) { + return 'local' + } try { const remoteNames = await listRemoteNamesViaExec(exec, probeOptions) @@ -171,7 +199,7 @@ export function getBranchConflictKind( ...(commandOptions?.stdin === undefined ? {} : { stdin: commandOptions.stdin }) }) return getBranchConflictKindViaExec( - runLocalGit, + (argv, commandOptions) => runLocalGit(argv, commandOptions, argv[0] === 'for-each-ref'), branchName, allowedBaseRef, {}, diff --git a/src/main/git/repo-default-base-ref.ts b/src/main/git/repo-default-base-ref.ts index 8fb3acd764e..77aef711bfe 100644 --- a/src/main/git/repo-default-base-ref.ts +++ b/src/main/git/repo-default-base-ref.ts @@ -1,5 +1,12 @@ import type { GitAdmissionTier } from '../../shared/rpc-contract/git-admission-tier-params' import { gitExecFileAsync } from './runner' +import { resolveDefaultBaseRefViaExec } from '../../shared/git-default-base-ref' + +export { + DEFAULT_BASE_REF_PROBES, + resolveDefaultBaseRefViaExec +} from '../../shared/git-default-base-ref' +export type { GitExec } from '../../shared/git-default-base-ref' export type LocalGitExecOptions = { wslDistro?: string @@ -25,28 +32,6 @@ export function gitExecOptions( } } -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 gitRefToDefaultBaseRef(ref: string): string { - return ref.replace(/^refs\/remotes\//, '') -} - export async function getBaseRefDefault( path: string, options: LocalGitExecOptions = {} @@ -54,39 +39,6 @@ export async function getBaseRefDefault( return getDefaultBaseRefAsync(path, options) } -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 same default-base ordering through a host-owned Git executor. */ -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 { diff --git a/src/main/git/repo-default-remote.test.ts b/src/main/git/repo-default-remote.test.ts index a0764598fb8..947cb90f233 100644 --- a/src/main/git/repo-default-remote.test.ts +++ b/src/main/git/repo-default-remote.test.ts @@ -16,11 +16,8 @@ describe('getDefaultRemote', () => { it('prefers the configured remote for the resolved default branch', async () => { gitExecFileAsyncMock.mockImplementation(async (argv: string[]) => { - if (argv[0] === 'symbolic-ref') { - return { stdout: 'refs/remotes/origin/main\n' } - } - if (argv[0] === 'rev-parse') { - return { stdout: 'abc123\n' } + if (argv[0] === 'for-each-ref') { + return { stdout: 'refs/remotes/origin/HEAD\0refs/remotes/origin/main\n' } } if (argv[0] === 'config') { return { stdout: 'upstream\n' } @@ -67,9 +64,7 @@ describe('getDefaultRemote', () => { }) it('normalizes a non-Error remote rejection', async () => { - for (let probe = 0; probe < 5; probe += 1) { - gitExecFileAsyncMock.mockRejectedValueOnce(new Error('missing ref')) - } + gitExecFileAsyncMock.mockRejectedValueOnce(new Error('missing ref')) gitExecFileAsyncMock.mockRejectedValueOnce('transport failed') await expect(getDefaultRemote('/repo')).rejects.toThrow( diff --git a/src/main/git/repo-detection-batching.test.ts b/src/main/git/repo-detection-batching.test.ts new file mode 100644 index 00000000000..7d9340dcc9f --- /dev/null +++ b/src/main/git/repo-detection-batching.test.ts @@ -0,0 +1,229 @@ +import { execFileSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import * as runner from './runner' +import { + getGitRepoRoot, + getLinkedWorktreeMainRepoRoot, + inspectGitRepoForRegistration, + isGitRepo +} from './repo-detection' + +function git(cwd: string, args: string[]): string { + return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: 'pipe' }) +} + +describe('repository registration probe batching', () => { + let directory: string + let repo: string + + beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), 'orca-repo-probe-count-')) + repo = join(directory, 'repo') + mkdirSync(repo) + git(repo, ['init', '-q']) + }) + + afterEach(() => { + vi.restoreAllMocks() + rmSync(directory, { recursive: true, force: true }) + }) + + it('answers registration validity, root and main-checkout identity with two probes', () => { + const probe = vi.spyOn(runner, 'gitExecFileSync') + expect(inspectGitRepoForRegistration(repo)).toEqual({ + isRepo: true, + rootPath: git(repo, ['rev-parse', '--show-toplevel']).trim().replace(/\\/g, '/'), + mainRepoPath: null + }) + expect(probe).toHaveBeenCalledTimes(2) + }) + + it('keeps linked main-checkout resolution lazy and reuses its metadata', () => { + git(repo, [ + '-c', + 'user.name=Test', + '-c', + 'user.email=test@example.invalid', + 'commit', + '-qm', + 'seed', + '--allow-empty' + ]) + const linked = join(directory, 'linked') + git(repo, ['worktree', 'add', '-q', '-b', 'linked', linked]) + const probe = vi.spyOn(runner, 'gitExecFileSync') + const inspected = inspectGitRepoForRegistration(linked) + expect(inspected.isRepo).toBe(true) + expect(inspected.rootPath).toBe( + git(linked, ['rev-parse', '--show-toplevel']).trim().replace(/\\/g, '/') + ) + expect(inspected.mainRepoPath).toBe(realpathSync(repo)) + expect(probe).toHaveBeenCalledTimes(2) + if (!inspected.mainRepoPath) { + throw new Error('Linked checkout did not identify its main checkout') + } + expect(getGitRepoRoot(inspected.mainRepoPath)).toBe( + git(repo, ['rev-parse', '--show-toplevel']).trim().replace(/\\/g, '/') + ) + expect(probe).toHaveBeenCalledTimes(3) + }) + + it('reads root and linked-main metadata together when a symlink changes between probes', () => { + const linked = join(directory, 'linked') + const alias = join(directory, 'alias') + git(repo, [ + '-c', + 'user.name=Test', + '-c', + 'user.email=test@example.invalid', + 'commit', + '-qm', + 'seed', + '--allow-empty' + ]) + git(repo, ['worktree', 'add', '-q', '-b', 'linked', linked]) + symlinkSync(repo, alias, process.platform === 'win32' ? 'junction' : 'dir') + const execute = runner.gitExecFileSync + const probe = vi.spyOn(runner, 'gitExecFileSync').mockImplementation((args, options) => { + const output = execute(args, options) + if (probe.mock.calls.length === 1) { + rmSync(alias) + symlinkSync(linked, alias, process.platform === 'win32' ? 'junction' : 'dir') + } + return output + }) + expect(inspectGitRepoForRegistration(alias)).toEqual({ + isRepo: true, + rootPath: git(linked, ['rev-parse', '--show-toplevel']).trim().replace(/\\/g, '/'), + mainRepoPath: realpathSync(repo) + }) + expect(probe).toHaveBeenCalledTimes(2) + }) + + it('identifies a bare repository without requesting a worktree root', () => { + const bare = join(directory, 'bare.git') + git(directory, ['init', '--bare', '-q', bare]) + const probe = vi.spyOn(runner, 'gitExecFileSync') + expect(inspectGitRepoForRegistration(bare)).toEqual({ + isRepo: true, + rootPath: bare, + mainRepoPath: null + }) + expect(probe).toHaveBeenCalledOnce() + expect(probe.mock.calls[0][0]).not.toContain('--show-toplevel') + }) + + it('rejects an administrative directory after one clean negative pair', () => { + const probe = vi.spyOn(runner, 'gitExecFileSync') + const admin = join(repo, '.git') + expect(inspectGitRepoForRegistration(admin)).toEqual({ + isRepo: false, + rootPath: admin, + mainRepoPath: null + }) + expect(probe).toHaveBeenCalledOnce() + }) + + it('does not repeat a failed Git discovery before using the marker fallback', () => { + const nested = join(repo, 'packages', 'web') + mkdirSync(nested, { recursive: true }) + const probe = vi.spyOn(runner, 'gitExecFileSync').mockImplementation(() => { + throw new Error('Git could not run') + }) + expect(inspectGitRepoForRegistration(nested)).toEqual({ + isRepo: true, + rootPath: repo.replace(/\\/g, '/'), + mainRepoPath: null + }) + expect(probe).toHaveBeenCalledOnce() + }) + + it('uses one boolean query for bare checks and none for a missing path', () => { + const bare = join(directory, 'bare.git') + git(directory, ['init', '--bare', '-q', bare]) + const probe = vi.spyOn(runner, 'gitExecFileSync') + expect(isGitRepo(bare)).toBe(true) + expect(probe).toHaveBeenCalledOnce() + expect(inspectGitRepoForRegistration(join(directory, 'missing')).isRepo).toBe(false) + expect(probe).toHaveBeenCalledOnce() + }) +}) + +// Windows rejects control characters in directory names. +describe.skipIf(process.platform === 'win32')('repository paths with newlines', () => { + let directory: string + + beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), 'orca-repo-newline-')) + }) + + afterEach(() => { + vi.restoreAllMocks() + rmSync(directory, { recursive: true, force: true }) + }) + + it.each(['repo\nname', 'repo\n\nname', ' repo name ', 'repo\n'])( + 'preserves the complete root %j instead of registering a prefix repository', + (name) => { + const prefixRepo = join(directory, 'repo') + mkdirSync(prefixRepo) + git(prefixRepo, ['init', '-q']) + const repo = join(directory, name) + const nested = join(repo, 'nested') + mkdirSync(nested, { recursive: true }) + git(repo, ['init', '-q']) + expect(getGitRepoRoot(nested)).toBe(realpathSync(repo)) + expect(inspectGitRepoForRegistration(nested)).toEqual({ + isRepo: true, + rootPath: realpathSync(repo), + mainRepoPath: null + }) + } + ) + + it('preserves an external Git directory containing a newline', () => { + const repo = join(directory, 'repo') + const admin = join(directory, 'external\nadmin') + mkdirSync(repo) + git(repo, ['init', '-q', '--separate-git-dir', admin]) + const probe = vi.spyOn(runner, 'gitExecFileSync') + expect(inspectGitRepoForRegistration(repo)).toEqual({ + isRepo: true, + rootPath: realpathSync(repo), + mainRepoPath: null + }) + expect(probe).toHaveBeenCalledTimes(5) + expect(probe.mock.calls.map(([args]) => args)).toContainEqual(['rev-parse', '--git-dir']) + expect(probe.mock.calls.map(([args]) => args)).toContainEqual(['rev-parse', '--git-common-dir']) + expect(getLinkedWorktreeMainRepoRoot(repo)).toBeNull() + }) + + it('resolves linked checkout ownership with newlines in the root and common directory', () => { + const repo = join(directory, 'main\n\nrepo') + mkdirSync(repo) + git(repo, ['init', '-q']) + git(repo, [ + '-c', + 'user.name=Test', + '-c', + 'user.email=test@example.invalid', + '-c', + 'commit.gpgSign=false', + 'commit', + '-qm', + 'seed', + '--allow-empty' + ]) + const linked = join(directory, 'linked\nrepo') + git(repo, ['worktree', 'add', '-q', '-b', 'linked', linked]) + expect(inspectGitRepoForRegistration(linked)).toEqual({ + isRepo: true, + rootPath: realpathSync(linked), + mainRepoPath: realpathSync(repo) + }) + expect(getLinkedWorktreeMainRepoRoot(linked)).toBe(realpathSync(repo)) + }) +}) diff --git a/src/main/git/repo-detection.ts b/src/main/git/repo-detection.ts index f9105b7e26f..3fe75a1148d 100644 --- a/src/main/git/repo-detection.ts +++ b/src/main/git/repo-detection.ts @@ -7,6 +7,18 @@ import { scanGitMarkerSync, resolveRealPathSync } from './repo-git-marker-scan' import { gitExecFileSync } from './runner' type GitRepoProbeResult = 'repo' | 'not-repo' | 'indeterminate' +type GitRepoProbe = { + result: GitRepoProbeResult + insideWorkTree?: boolean + gitDir?: string + commonDir?: string +} + +export type GitRepoRegistrationInfo = { + isRepo: boolean + rootPath: string + mainRepoPath: string | null +} let warnedMarkerFallbackThisSession = false @@ -20,11 +32,14 @@ export function isGitRepo(path: string): boolean { return false } - const gitProbeResult = probeGitRepo(path) - if (gitProbeResult === 'repo') { + return isGitRepoFromProbe(path, probeGitRepo(path).result) +} + +function isGitRepoFromProbe(path: string, result: GitRepoProbeResult): boolean { + if (result === 'repo') { return true } - if (gitProbeResult === 'not-repo') { + if (result === 'not-repo') { return false } @@ -39,38 +54,95 @@ export function isGitRepo(path: string): boolean { } /** Only a clean pair of negative Git answers is a definitive non-repo. */ -function probeGitRepo(path: string): GitRepoProbeResult { - let sawFailure = false - +function probeGitRepo(path: string, includeLocation = false): GitRepoProbe { try { - const insideWorkTree = gitExecFileSync(['rev-parse', '--is-inside-work-tree'], { - cwd: path - }).trim() - if (insideWorkTree === 'true') { - return 'repo' - } - if (insideWorkTree !== 'false') { - return 'indeterminate' + const records = readGitPathOutput( + gitExecFileSync( + [ + 'rev-parse', + '--is-inside-work-tree', + '--is-bare-repository', + ...(includeLocation ? ['--git-dir', '--git-common-dir'] : []) + ], + { cwd: path } + ) + ).split('\n') + const [insideWorkTree, bareRepo, gitDir, commonDir] = records + const result = + insideWorkTree === 'true' || bareRepo === 'true' + ? 'repo' + : insideWorkTree === 'false' && bareRepo === 'false' + ? 'not-repo' + : 'indeterminate' + const location = + includeLocation && insideWorkTree === 'true' && records.length !== 4 + ? readGitRepoDirectories(path) + : { gitDir, commonDir } + return { result, insideWorkTree: insideWorkTree === 'true', ...location } + } catch { + return { result: 'indeterminate' } + } +} + +/** Reuse one repository discovery across registration's validity, root and worktree checks. */ +export function inspectGitRepoForRegistration(path: string): GitRepoRegistrationInfo { + try { + if (!statSync(path, { throwIfNoEntry: false })?.isDirectory()) { + return { isRepo: false, rootPath: path, mainRepoPath: null } } } catch { - sawFailure = true + return { isRepo: false, rootPath: path, mainRepoPath: null } } - - try { - const bareRepo = gitExecFileSync(['rev-parse', '--is-bare-repository'], { - cwd: path - }).trim() - if (bareRepo === 'true') { - return 'repo' + const probe = probeGitRepo(path) + const isRepo = isGitRepoFromProbe(path, probe.result) + let rootPath = path + let mainRepoPath: string | null = null + if (isRepo && probe.insideWorkTree) { + try { + const records = readGitPathOutput( + gitExecFileSync( + [ + 'rev-parse', + '--is-inside-work-tree', + '--show-toplevel', + '--git-dir', + '--git-common-dir' + ], + { cwd: path } + ) + ).split('\n') + const [insideWorkTree, toplevel, gitDir, commonDir] = records + if (insideWorkTree === 'true') { + // Newlines in paths make the combined records ambiguous. + const location = + records.length === 4 && toplevel && gitDir && commonDir + ? { toplevel, gitDir, commonDir } + : { + toplevel: readGitPathOutput( + gitExecFileSync(['rev-parse', '--show-toplevel'], { cwd: path }) + ), + ...readGitRepoDirectories(path) + } + if (location.toplevel) { + rootPath = normalizeGitRepoRootForInputPath(path, location.toplevel) + mainRepoPath = mainRepoPathFromProbe(path, { + result: 'repo', + insideWorkTree: true, + ...location + }) + } else { + rootPath = rootPathFromMarker(path) + } + } else { + rootPath = rootPathFromMarker(path) + } + } catch { + rootPath = rootPathFromMarker(path) } - if (bareRepo !== 'false') { - return 'indeterminate' - } - } catch { - sawFailure = true + } else if (isRepo) { + rootPath = rootPathFromMarker(path) } - - return sawFailure ? 'indeterminate' : 'not-repo' + return { isRepo, rootPath, mainRepoPath } } export function getGitRepoRoot(path: string): string { @@ -78,23 +150,35 @@ export function getGitRepoRoot(path: string): string { if (!existsSync(path) || !statSync(path).isDirectory()) { return path } - // One spawn, not two: each sync git call blocks main for up to its whole 15s - // timeout, so the spawn count is the cost. Safe to combine only here — a bare - // repo makes the combined form exit non-zero, and both that throw and the - // plain `false` land on the same marker-scan fallback below. `probeGitRepo` - // must NOT combine: it has to read `false` cleanly to go on and detect bare. - const [insideWorkTree, toplevel] = gitExecFileSync( - ['rev-parse', '--is-inside-work-tree', '--show-toplevel'], - { cwd: path } - ) - .split('\n') - .map((line) => line.trim()) - if (insideWorkTree === 'true' && toplevel) { - return normalizeGitRepoRootForInputPath(path, toplevel) + // A bare repo has no toplevel; keep its marker fallback separate from the boolean probes. + const output = gitExecFileSync(['rev-parse', '--is-inside-work-tree', '--show-toplevel'], { + cwd: path + }) + const firstNewline = output.indexOf('\n') + if (firstNewline !== -1 && output.slice(0, firstNewline).trim() === 'true') { + const toplevel = readGitPathOutput(output.slice(firstNewline + 1)) + if (toplevel) { + return normalizeGitRepoRootForInputPath(path, toplevel) + } } } catch { // Fall through to preserving the original path. } + return rootPathFromMarker(path) +} + +function readGitPathOutput(output: string): string { + return output.endsWith('\n') ? output.slice(0, -1) : output +} + +function readGitRepoDirectories(path: string): Pick { + return { + gitDir: readGitPathOutput(gitExecFileSync(['rev-parse', '--git-dir'], { cwd: path })), + commonDir: readGitPathOutput(gitExecFileSync(['rev-parse', '--git-common-dir'], { cwd: path })) + } +} + +function rootPathFromMarker(path: string): string { const markerScan = scanGitMarkerSync(path) if (markerScan.status === 'valid') { return normalizeGitRepoRootForInputPath(path, markerScan.rootPath) @@ -112,30 +196,27 @@ export function getLinkedWorktreeMainRepoRoot(path: string): string | null { if (!statSync(path, { throwIfNoEntry: false })?.isDirectory()) { return null } - if (gitExecFileSync(['rev-parse', '--is-inside-work-tree'], { cwd: path }).trim() !== 'true') { - return null - } - const [gitDir, commonDir] = gitExecFileSync(['rev-parse', '--git-dir', '--git-common-dir'], { - cwd: path - }) - .split('\n') - .map((line) => line.trim()) - if (!gitDir || !commonDir) { - return null - } - const absoluteCommonDir = canonicalizeGitDirPath(resolve(path, commonDir)) - if (canonicalizeGitDirPath(resolve(path, gitDir)) === absoluteCommonDir) { - return null - } - if (basename(absoluteCommonDir) !== '.git') { - return null - } - return getGitRepoRoot(dirname(absoluteCommonDir)) + const mainRepoPath = mainRepoPathFromProbe(path, probeGitRepo(path, true)) + return mainRepoPath ? getGitRepoRoot(mainRepoPath) : null } catch { return null } } +function mainRepoPathFromProbe(path: string, probe: GitRepoProbe): string | null { + if (!probe.insideWorkTree || !probe.gitDir || !probe.commonDir) { + return null + } + const absoluteCommonDir = canonicalizeGitDirPath(resolve(path, probe.commonDir)) + if ( + canonicalizeGitDirPath(resolve(path, probe.gitDir)) === absoluteCommonDir || + basename(absoluteCommonDir) !== '.git' + ) { + return null + } + return dirname(absoluteCommonDir) +} + export function normalizeGitRepoRootForInputPath(inputPath: string, rootPath: string): string { const inputWsl = parseWslUncPath(inputPath) if (inputWsl && rootPath.startsWith('/')) { diff --git a/src/main/git/repo-remote-drift.test.ts b/src/main/git/repo-remote-drift.test.ts index 3d0b7ef10e9..83b445c5e1e 100644 --- a/src/main/git/repo-remote-drift.test.ts +++ b/src/main/git/repo-remote-drift.test.ts @@ -55,7 +55,7 @@ describe('remote drift Git probes', () => { 'older subject' ]) expect(gitExecFileAsyncMock).toHaveBeenCalledWith( - ['log', '--format=%s', '-n', '5', 'HEAD..origin/main'], + ['log', '--no-show-signature', '--no-color', '--format=%s', '-n', '5', 'HEAD..origin/main'], { cwd: '/repo', timeout: 15_000 } ) diff --git a/src/main/git/repo.test.ts b/src/main/git/repo.test.ts index 4c75886a922..ca03de55494 100644 --- a/src/main/git/repo.test.ts +++ b/src/main/git/repo.test.ts @@ -589,6 +589,23 @@ describe('getBaseRefDefault (regression — unchanged behavior)', async () => { expect(result).toBe('origin/master') }) + it('resolves symbolic chains to a default branch outside the primary candidates', async () => { + const sha = getHeadSha(tmpDir) + createRemoteRef(tmpDir, 'origin/release/stable', sha) + git(tmpDir, ['symbolic-ref', 'refs/remotes/origin/alias', 'refs/remotes/origin/release/stable']) + git(tmpDir, ['symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/alias']) + + await expect(getBaseRefDefault(tmpDir)).resolves.toBe('origin/release/stable') + }) + + it('ignores descendants of primary candidates', async () => { + const sha = getHeadSha(tmpDir) + createRemoteRef(tmpDir, 'origin/main/topic', sha) + createRemoteRef(tmpDir, 'origin/master', sha) + + await expect(getBaseRefDefault(tmpDir)).resolves.toBe('origin/master') + }) + it('does NOT fall through to upstream/main when origin/* is absent', async () => { // Why: default probe order is origin-only by design; upstream-aware defaulting is deferred. const sha = getHeadSha(tmpDir) @@ -602,50 +619,36 @@ describe('getBaseRefDefault (regression — unchanged behavior)', async () => { }) }) -describe('resolveDefaultBaseRefViaExec', async () => { - it('falls through from a stale origin/HEAD target to the probe list', async () => { +describe('resolveDefaultBaseRefViaExec', () => { + it('resolves the primary fallback ordering with one exact ref query', async () => { const calls: string[][] = [] const exec = async (argv: string[]): Promise<{ stdout: string }> => { calls.push(argv) - if (argv[0] === 'symbolic-ref') { - return { stdout: 'refs/remotes/origin/master\n' } + return { + stdout: 'refs/heads/main\0\nrefs/remotes/origin/master\0\nrefs/remotes/origin/main\0\n' } - if (argv[0] === 'rev-parse' && argv.at(-1) === 'refs/remotes/origin/main') { - return { stdout: 'main-sha\n' } - } - throw new Error('missing ref') } await expect(resolveDefaultBaseRefViaExec(exec)).resolves.toBe('origin/main') - expect(calls).toEqual([ - ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], - ['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/master'], - ['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main'] + [ + 'for-each-ref', + '--format=%(refname)%00%(symref)', + 'refs/remotes/origin/HEA[D]', + 'refs/remotes/origin/mai[n]', + 'refs/remotes/origin/maste[r]', + 'refs/heads/mai[n]', + 'refs/heads/maste[r]' + ] ]) }) - it('verifies origin/HEAD even when it points at origin/main', async () => { - const calls: string[][] = [] - const exec = async (argv: string[]): Promise<{ stdout: string }> => { - calls.push(argv) - if (argv[0] === 'symbolic-ref') { - return { stdout: 'refs/remotes/origin/main\n' } - } - if (argv[0] === 'rev-parse' && argv.at(-1) === 'refs/remotes/origin/master') { - return { stdout: 'master-sha\n' } - } - throw new Error('missing ref') - } - - await expect(resolveDefaultBaseRefViaExec(exec)).resolves.toBe('origin/master') - - expect(calls).toEqual([ - ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], - ['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main'], - ['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main'], - ['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/master'] - ]) + it('returns null if the host cannot read the ref table', async () => { + await expect( + resolveDefaultBaseRefViaExec(async () => { + throw new Error('unavailable host') + }) + ).resolves.toBeNull() }) }) diff --git a/src/main/git/repo.ts b/src/main/git/repo.ts index decbfa6182c..392b5651818 100644 --- a/src/main/git/repo.ts +++ b/src/main/git/repo.ts @@ -11,6 +11,7 @@ import { gitExecFileAsync } from './runner' export { isGitRepo, + inspectGitRepoForRegistration, getGitRepoRoot, getLinkedWorktreeMainRepoRoot, normalizeGitRepoRootForInputPath @@ -85,7 +86,15 @@ export async function getRecentDriftSubjects( ): Promise { try { const { stdout } = await gitExecFileAsync( - ['log', '--format=%s', '-n', String(limit), `${localRef}..${remoteRef}`], + [ + 'log', + '--no-show-signature', + '--no-color', + '--format=%s', + '-n', + String(limit), + `${localRef}..${remoteRef}` + ], { ...gitExecOptions(repoPath, options), timeout: DEFAULT_BASE_REF_PROBE_TIMEOUT_MS diff --git a/src/main/git/runner-buffer-cancellation.test.ts b/src/main/git/runner-buffer-cancellation.test.ts new file mode 100644 index 00000000000..59a17a046de --- /dev/null +++ b/src/main/git/runner-buffer-cancellation.test.ts @@ -0,0 +1,97 @@ +import { EventEmitter } from 'node:events' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() })) +vi.mock('node:child_process', () => ({ + execFile: execFileMock, + execFileSync: vi.fn(), + spawn: vi.fn() +})) + +import { gitExecFileAsyncBuffer } from './runner' +import { + GitAdmissionScheduler, + _resetGitAdmissionForTests +} from './command-runner/git-subprocess-admission' +import { configureWindowsHostGitEnvironmentReadiness } from './command-runner/windows-host-git-environment' + +beforeEach(() => execFileMock.mockReset()) +afterEach(() => { + _resetGitAdmissionForTests() + configureWindowsHostGitEnvironmentReadiness(null) +}) + +describe('buffered Git cancellation', () => { + it('rejects an already canceled read without launching Git', async () => { + const controller = new AbortController() + controller.abort() + await expect( + gitExecFileAsyncBuffer(['show', 'HEAD:file'], { cwd: '/repo', signal: controller.signal }) + ).rejects.toMatchObject({ name: 'AbortError' }) + expect(execFileMock).not.toHaveBeenCalled() + }) + + it('removes a canceled queued read without waiting for the active child', async () => { + const scheduler = new GitAdmissionScheduler({ generalCap: 1, generalHeadroom: 0 }) + _resetGitAdmissionForTests(scheduler) + const grant = await scheduler.acquire({ args: ['show'], cwd: '/repo' }) + try { + const controller = new AbortController() + const read = gitExecFileAsyncBuffer(['show', 'HEAD:file'], { + cwd: '/repo', + signal: controller.signal + }) + const rejected = expect(read).rejects.toMatchObject({ name: 'AbortError' }) + await vi.waitFor(() => expect(scheduler.snapshot().queued).toBe(1)) + controller.abort() + await rejected + expect(scheduler.snapshot().queued).toBe(0) + expect(execFileMock).not.toHaveBeenCalled() + } finally { + grant.release() + } + }) + + it('kills an active child while retaining its admission slot until close', async () => { + const scheduler = new GitAdmissionScheduler({ generalCap: 1, generalHeadroom: 0 }) + _resetGitAdmissionForTests(scheduler) + const child = Object.assign(new EventEmitter(), { + stdout: new EventEmitter(), + stderr: new EventEmitter(), + kill: vi.fn() + }) + execFileMock.mockReturnValue(child) + const controller = new AbortController() + const read = gitExecFileAsyncBuffer(['show', 'HEAD:file'], { + cwd: '/repo', + signal: controller.signal + }) + const rejected = expect(read).rejects.toMatchObject({ name: 'AbortError' }) + await vi.waitFor(() => expect(execFileMock).toHaveBeenCalledOnce()) + controller.abort() + await rejected + expect(child.kill).toHaveBeenCalledOnce() + expect(scheduler.snapshot().budgets.general.baseUsed).toBe(1) + child.emit('close', 0) + await vi.waitFor(() => expect(scheduler.snapshot().budgets.general.baseUsed).toBe(0)) + }) + + it('cancels Windows environment readiness without launching Git', async () => { + const platform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + try { + configureWindowsHostGitEnvironmentReadiness(() => new Promise(() => {})) + const controller = new AbortController() + const read = gitExecFileAsyncBuffer(['show', 'HEAD:file'], { + cwd: String.raw`C:\repo`, + signal: controller.signal + }) + const rejected = expect(read).rejects.toMatchObject({ name: 'AbortError' }) + controller.abort() + await rejected + expect(execFileMock).not.toHaveBeenCalled() + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) + } + }) +}) diff --git a/src/main/git/runner-command-exec.test.ts b/src/main/git/runner-command-exec.test.ts index 89bcc4dca15..48f689f042d 100644 --- a/src/main/git/runner-command-exec.test.ts +++ b/src/main/git/runner-command-exec.test.ts @@ -22,10 +22,7 @@ import { translateWslOutputPaths, wslAwareSpawn } from './runner' -import { - GitAdmissionScheduler, - _resetGitAdmissionForTests -} from './command-runner/git-subprocess-admission' +import { _resetGitAdmissionForTests } from './command-runner/git-subprocess-admission' afterEach(() => _resetGitAdmissionForTests()) @@ -456,220 +453,6 @@ describe('runner execFile timeout handling', () => { expect(capturedEnv?.GIT_SSH_COMMAND).toContain('BatchMode=yes') }) - it('probes core.sshCommand for opted-in network git calls', async () => { - const child = createMockChildProcess(1234) - const calls: { args: string[]; env: NodeJS.ProcessEnv }[] = [] - execFileMock.mockImplementation((_cmd, args, opts, cb) => { - calls.push({ args, env: opts.env }) - cb(null, args[0] === 'config' ? 'ssh -F ~/.ssh/github-work -i ~/.ssh/work_key\n' : '', '') - return child - }) - - await gitExecFileAsync(['fetch', 'origin'], { - cwd: '/repo', - env: {}, - useConfiguredSshCommandForNetwork: true - }) - - expect(calls[0]?.args).toEqual(['config', '--get', 'core.sshCommand']) - expect(calls[0]?.env.GIT_TERMINAL_PROMPT).toBe('0') - expect(calls[0]?.env.GIT_SSH_COMMAND).toBeUndefined() - expect(calls[1]?.args).toEqual(['fetch', 'origin']) - expect(calls[1]?.env.GIT_SSH_COMMAND).toBe( - 'ssh -F ~/.ssh/github-work -i ~/.ssh/work_key -o BatchMode=yes' - ) - }) - - it('admits the core.sshCommand probe before spawning it', async () => { - const scheduler = new GitAdmissionScheduler({ generalCap: 1, generalHeadroom: 0 }) - _resetGitAdmissionForTests(scheduler) - const blocker = await scheduler.acquire({ args: ['status'], cwd: '/repo', tier: 'status' }) - const calls: string[][] = [] - execFileMock.mockImplementation((_cmd, args, _opts, cb) => { - const child = createMockChildProcess(1234 + calls.length) - calls.push(args) - cb(null, '', '') - queueMicrotask(() => child.emit('close', 0, null)) - return child - }) - - const pending = gitExecFileAsync(['fetch', '--no-write-fetch-head', 'origin'], { - cwd: '/repo', - env: {}, - useConfiguredSshCommandForNetwork: true - }) - await Promise.resolve() - expect(execFileMock).not.toHaveBeenCalled() - - blocker.release() - await pending - - expect(calls).toEqual([ - ['config', '--get', 'core.sshCommand'], - ['fetch', '--no-write-fetch-head', 'origin'] - ]) - }) - - it('replaces configured BatchMode for opted-in mergeable OpenSSH commands', async () => { - const child = createMockChildProcess(1234) - let capturedEnv: NodeJS.ProcessEnv | undefined - execFileMock.mockImplementation((_cmd, args, opts, cb) => { - if (args[0] === 'config') { - cb(null, 'ssh -o BatchMode=no -i ~/.ssh/personal\n', '') - } else { - capturedEnv = opts.env - cb(null, '', '') - } - return child - }) - - await gitExecFileAsync(['fetch', 'origin'], { - cwd: '/repo', - env: {}, - useConfiguredSshCommandForNetwork: true - }) - - expect(capturedEnv?.GIT_SSH_COMMAND).toBe('ssh -i ~/.ssh/personal -o BatchMode=yes') - }) - - it('merges quoted ssh.exe command shapes for opted-in network calls', async () => { - const child = createMockChildProcess(1234) - let capturedEnv: NodeJS.ProcessEnv | undefined - execFileMock.mockImplementation((_cmd, args, opts, cb) => { - if (args[0] === 'config') { - cb(null, '"C:/Program Files/Git/usr/bin/ssh.exe" -F ~/.ssh/config\n', '') - } else { - capturedEnv = opts.env - cb(null, '', '') - } - return child - }) - - await gitExecFileAsync(['fetch', 'origin'], { - cwd: '/repo', - env: {}, - useConfiguredSshCommandForNetwork: true - }) - - expect(capturedEnv?.GIT_SSH_COMMAND).toBe( - "'C:/Program Files/Git/usr/bin/ssh.exe' -F ~/.ssh/config -o BatchMode=yes" - ) - }) - - it('merges unquoted Windows ssh.exe paths for opted-in network calls', async () => { - const child = createMockChildProcess(1234) - let capturedEnv: NodeJS.ProcessEnv | undefined - execFileMock.mockImplementation((_cmd, args, opts, cb) => { - if (args[0] === 'config') { - cb(null, `${String.raw`C:\Git\usr\bin\ssh.exe -i C:\Users\me\.ssh\work_key`}\n`, '') - } else { - capturedEnv = opts.env - cb(null, '', '') - } - return child - }) - - await gitExecFileAsync(['fetch', 'origin'], { - cwd: '/repo', - env: {}, - useConfiguredSshCommandForNetwork: true - }) - - expect(capturedEnv?.GIT_SSH_COMMAND).toBe( - String.raw`'C:\Git\usr\bin\ssh.exe' -i 'C:\Users\me\.ssh\work_key' -o BatchMode=yes` - ) - }) - - it('passes through unmergeable core.sshCommand wrappers without generic fallback', async () => { - const child = createMockChildProcess(1234) - let capturedEnv: NodeJS.ProcessEnv | undefined - execFileMock.mockImplementation((_cmd, args, opts, cb) => { - if (args[0] === 'config') { - cb(null, '/usr/local/bin/work-ssh-wrapper --account work\n', '') - } else { - capturedEnv = opts.env - cb(null, '', '') - } - return child - }) - - await gitExecFileAsync(['fetch', 'origin'], { - cwd: '/repo', - env: {}, - useConfiguredSshCommandForNetwork: true - }) - - expect(capturedEnv?.GIT_TERMINAL_PROMPT).toBe('0') - expect(capturedEnv?.GIT_ASKPASS).toBe('') - expect(capturedEnv?.SSH_ASKPASS).toBe('') - expect(capturedEnv?.GIT_SSH_COMMAND).toBeUndefined() - }) - - it('passes through shell-expanding OpenSSH configs without changing expansion semantics', async () => { - const child = createMockChildProcess(1234) - let capturedEnv: NodeJS.ProcessEnv | undefined - execFileMock.mockImplementation((_cmd, args, opts, cb) => { - if (args[0] === 'config') { - cb(null, 'ssh -i "$HOME/.ssh/work_key"\n', '') - } else { - capturedEnv = opts.env - cb(null, '', '') - } - return child - }) - - await gitExecFileAsync(['fetch', 'origin'], { - cwd: '/repo', - env: {}, - useConfiguredSshCommandForNetwork: true - }) - - expect(capturedEnv?.GIT_TERMINAL_PROMPT).toBe('0') - expect(capturedEnv?.GIT_SSH_COMMAND).toBeUndefined() - }) - - it('falls back to generic batch-mode SSH when opted-in config is unset', async () => { - const child = createMockChildProcess(1234) - let capturedEnv: NodeJS.ProcessEnv | undefined - execFileMock.mockImplementation((_cmd, args, opts, cb) => { - if (args[0] === 'config') { - cb(Object.assign(new Error('missing'), { code: 1 }), '', '') - } else { - capturedEnv = opts.env - cb(null, '', '') - } - return child - }) - - await gitExecFileAsync(['fetch', 'origin'], { - cwd: '/repo', - env: {}, - useConfiguredSshCommandForNetwork: true - }) - - expect(capturedEnv?.GIT_SSH_COMMAND).toBe('ssh -o BatchMode=yes') - }) - - it('preserves explicit GIT_SSH_COMMAND and skips the opted-in config probe', async () => { - const child = createMockChildProcess(1234) - let capturedEnv: NodeJS.ProcessEnv | undefined - execFileMock.mockImplementation((_cmd, _args, opts, cb) => { - capturedEnv = opts.env - cb(null, '', '') - return child - }) - - await gitExecFileAsync(['fetch', 'origin'], { - cwd: '/repo', - env: { GIT_SSH_COMMAND: 'custom-ssh -o IdentityAgent=none' }, - useConfiguredSshCommandForNetwork: true - }) - - expect(execFileMock).toHaveBeenCalledTimes(1) - expect(capturedEnv?.GIT_SSH_COMMAND).toBe('custom-ssh -o IdentityAgent=none') - expect(capturedEnv?.GIT_TERMINAL_PROMPT).toBe('0') - }) - it('routes git through the selected WSL distro login shell when requested', async () => { await withPlatform('win32', async () => { const child = createMockChildProcess(1234) diff --git a/src/main/git/runner-network-ssh-policy.test.ts b/src/main/git/runner-network-ssh-policy.test.ts new file mode 100644 index 00000000000..6c4263e1a48 --- /dev/null +++ b/src/main/git/runner-network-ssh-policy.test.ts @@ -0,0 +1,281 @@ +import { EventEmitter } from 'node:events' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() })) +vi.mock('node:child_process', () => ({ + execFile: execFileMock, + execFileSync: vi.fn(), + spawn: vi.fn() +})) + +import { gitExecFileAsync } from './runner' +import { + GitAdmissionScheduler, + _resetGitAdmissionForTests +} from './command-runner/git-subprocess-admission' + +class MockChildProcess extends EventEmitter { + stdout = new EventEmitter() + stderr = new EventEmitter() + kill = vi.fn() + constructor(readonly pid: number) { + super() + } +} +const createMockChildProcess = (pid: number): MockChildProcess => new MockChildProcess(pid) + +beforeEach(() => { + execFileMock.mockReset() +}) +afterEach(() => _resetGitAdmissionForTests()) + +describe('network Git SSH policy', () => { + it('probes core.sshCommand for opted-in network git calls', async () => { + const child = createMockChildProcess(1234) + const calls: { args: string[]; env: NodeJS.ProcessEnv }[] = [] + execFileMock.mockImplementation((_cmd, args, opts, cb) => { + calls.push({ args, env: opts.env }) + cb( + null, + args[0] === 'config' + ? 'core.sshcommand\nssh -F ~/.ssh/github-work -i ~/.ssh/work_key\0' + : '', + '' + ) + return child + }) + + await gitExecFileAsync(['fetch', 'origin'], { + cwd: '/repo', + env: {}, + useConfiguredSshCommandForNetwork: true + }) + + expect(calls[0]?.args).toEqual([ + 'config', + '--null', + '--get-regexp', + '^(core\\.sshcommand|ssh\\.variant)$' + ]) + expect(calls[0]?.env.GIT_TERMINAL_PROMPT).toBe('0') + expect(calls[0]?.env.GIT_SSH_COMMAND).toBeUndefined() + expect(calls[1]?.args).toEqual(['fetch', 'origin']) + expect(calls[1]?.env.GIT_SSH_COMMAND).toBe( + 'ssh -F ~/.ssh/github-work -i ~/.ssh/work_key -o BatchMode=yes' + ) + }) + + it('admits the core.sshCommand probe before spawning it', async () => { + const scheduler = new GitAdmissionScheduler({ generalCap: 1, generalHeadroom: 0 }) + _resetGitAdmissionForTests(scheduler) + const blocker = await scheduler.acquire({ args: ['status'], cwd: '/repo', tier: 'status' }) + const calls: string[][] = [] + execFileMock.mockImplementation((_cmd, args, _opts, cb) => { + const child = createMockChildProcess(1234 + calls.length) + calls.push(args) + cb(null, '', '') + queueMicrotask(() => child.emit('close', 0, null)) + return child + }) + + const pending = gitExecFileAsync(['fetch', '--no-write-fetch-head', 'origin'], { + cwd: '/repo', + env: {}, + useConfiguredSshCommandForNetwork: true + }) + await Promise.resolve() + expect(execFileMock).not.toHaveBeenCalled() + + blocker.release() + await pending + + expect(calls).toEqual([ + ['config', '--null', '--get-regexp', '^(core\\.sshcommand|ssh\\.variant)$'], + ['fetch', '--no-write-fetch-head', 'origin'] + ]) + }) + + it('replaces configured BatchMode for opted-in mergeable OpenSSH commands', async () => { + const child = createMockChildProcess(1234) + let capturedEnv: NodeJS.ProcessEnv | undefined + execFileMock.mockImplementation((_cmd, args, opts, cb) => { + if (args[0] === 'config') { + cb(null, 'core.sshcommand\nssh -o BatchMode=no -i ~/.ssh/personal\0', '') + } else { + capturedEnv = opts.env + cb(null, '', '') + } + return child + }) + + await gitExecFileAsync(['fetch', 'origin'], { + cwd: '/repo', + env: {}, + useConfiguredSshCommandForNetwork: true + }) + + expect(capturedEnv?.GIT_SSH_COMMAND).toBe('ssh -i ~/.ssh/personal -o BatchMode=yes') + }) + + it('merges quoted ssh.exe command shapes for opted-in network calls', async () => { + const child = createMockChildProcess(1234) + let capturedEnv: NodeJS.ProcessEnv | undefined + execFileMock.mockImplementation((_cmd, args, opts, cb) => { + if (args[0] === 'config') { + cb(null, 'core.sshcommand\n"C:/Program Files/Git/usr/bin/ssh.exe" -F ~/.ssh/config\0', '') + } else { + capturedEnv = opts.env + cb(null, '', '') + } + return child + }) + + await gitExecFileAsync(['fetch', 'origin'], { + cwd: '/repo', + env: {}, + useConfiguredSshCommandForNetwork: true + }) + + expect(capturedEnv?.GIT_SSH_COMMAND).toBe( + "'C:/Program Files/Git/usr/bin/ssh.exe' -F ~/.ssh/config -o BatchMode=yes" + ) + }) + + it('merges unquoted Windows ssh.exe paths for opted-in network calls', async () => { + const child = createMockChildProcess(1234) + let capturedEnv: NodeJS.ProcessEnv | undefined + execFileMock.mockImplementation((_cmd, args, opts, cb) => { + if (args[0] === 'config') { + cb( + null, + `core.sshcommand\n${String.raw`C:\Git\usr\bin\ssh.exe -i C:\Users\me\.ssh\work_key`}\0`, + '' + ) + } else { + capturedEnv = opts.env + cb(null, '', '') + } + return child + }) + + await gitExecFileAsync(['fetch', 'origin'], { + cwd: '/repo', + env: {}, + useConfiguredSshCommandForNetwork: true + }) + + expect(capturedEnv?.GIT_SSH_COMMAND).toBe( + String.raw`'C:\Git\usr\bin\ssh.exe' -i 'C:\Users\me\.ssh\work_key' -o BatchMode=yes` + ) + }) + + it('passes through unmergeable core.sshCommand wrappers without generic fallback', async () => { + const child = createMockChildProcess(1234) + let capturedEnv: NodeJS.ProcessEnv | undefined + execFileMock.mockImplementation((_cmd, args, opts, cb) => { + if (args[0] === 'config') { + cb(null, 'core.sshcommand\n/usr/local/bin/work-ssh-wrapper --account work\0', '') + } else { + capturedEnv = opts.env + cb(null, '', '') + } + return child + }) + + await gitExecFileAsync(['fetch', 'origin'], { + cwd: '/repo', + env: {}, + useConfiguredSshCommandForNetwork: true + }) + + expect(capturedEnv?.GIT_TERMINAL_PROMPT).toBe('0') + expect(capturedEnv?.GIT_ASKPASS).toBe('') + expect(capturedEnv?.SSH_ASKPASS).toBe('') + expect(capturedEnv?.GIT_SSH_COMMAND).toBeUndefined() + }) + + it('passes through shell-expanding OpenSSH configs without changing expansion semantics', async () => { + const child = createMockChildProcess(1234) + let capturedEnv: NodeJS.ProcessEnv | undefined + execFileMock.mockImplementation((_cmd, args, opts, cb) => { + if (args[0] === 'config') { + cb(null, 'core.sshcommand\nssh -i "$HOME/.ssh/work_key"\0', '') + } else { + capturedEnv = opts.env + cb(null, '', '') + } + return child + }) + + await gitExecFileAsync(['fetch', 'origin'], { + cwd: '/repo', + env: {}, + useConfiguredSshCommandForNetwork: true + }) + + expect(capturedEnv?.GIT_TERMINAL_PROMPT).toBe('0') + expect(capturedEnv?.GIT_SSH_COMMAND).toBeUndefined() + }) + + it('falls back to generic batch-mode SSH when opted-in config is unset', async () => { + const child = createMockChildProcess(1234) + let capturedEnv: NodeJS.ProcessEnv | undefined + execFileMock.mockImplementation((_cmd, args, opts, cb) => { + if (args[0] === 'config') { + cb(Object.assign(new Error('missing'), { code: 1 }), '', '') + } else { + capturedEnv = opts.env + cb(null, '', '') + } + return child + }) + + await gitExecFileAsync(['fetch', 'origin'], { + cwd: '/repo', + env: {}, + useConfiguredSshCommandForNetwork: true + }) + + expect(capturedEnv?.GIT_SSH_COMMAND).toBe('ssh -o BatchMode=yes') + }) + + it.each([ + { GIT_SSH_COMMAND: 'custom-ssh -o IdentityAgent=none' }, + { GIT_SSH: 'custom-ssh-wrapper' } + ])('preserves explicit SSH environment and skips the config probe (%j)', async (env) => { + const child = createMockChildProcess(1234) + let capturedEnv: NodeJS.ProcessEnv | undefined + execFileMock.mockImplementation((_cmd, _args, opts, cb) => { + capturedEnv = opts.env + cb(null, '', '') + return child + }) + + await gitExecFileAsync(['fetch', 'origin'], { + cwd: '/repo', + env, + useConfiguredSshCommandForNetwork: true + }) + + expect(execFileMock).toHaveBeenCalledTimes(1) + expect(capturedEnv?.GIT_SSH_COMMAND).toBe(env.GIT_SSH_COMMAND) + expect(capturedEnv?.GIT_SSH).toBe(env.GIT_SSH) + expect(capturedEnv?.GIT_TERMINAL_PROMPT).toBe('0') + }) + + it.each(['ETIMEDOUT', 'ENOBUFS', 'ABORT_ERR'])( + 'does not select a generic SSH command after a %s config failure', + async (code) => { + const error = Object.assign(new Error('SSH configuration unavailable'), { code }) + execFileMock.mockImplementation((_cmd, _args, _opts, cb) => { + cb(error, '', '') + return createMockChildProcess(1234) + }) + await expect(gitExecFileAsync(['fetch', 'origin'], { cwd: '/repo', env: {} })).rejects.toBe( + error + ) + expect(execFileMock).toHaveBeenCalledTimes(1) + expect(execFileMock.mock.calls[0]?.[1]?.[0]).toBe('config') + } + ) +}) diff --git a/src/main/git/runner-windows-host-environment.test.ts b/src/main/git/runner-windows-host-environment.test.ts index 367d830b767..fe893e7f0e6 100644 --- a/src/main/git/runner-windows-host-environment.test.ts +++ b/src/main/git/runner-windows-host-environment.test.ts @@ -112,6 +112,10 @@ describe('Windows host Git environment readiness', () => { const waitUntilReady = vi.fn(() => ready.promise) const child = createMockChildProcess(1234) spawnMock.mockReturnValue(child) + execFileMock.mockImplementation((_cmd, _args, _options, callback) => { + callback(Object.assign(new Error('missing SSH config'), { code: 1 }), '', '') + return child + }) configureWindowsHostGitEnvironmentReadiness(waitUntilReady) process.env.Path = 'hydrating-path' @@ -136,6 +140,10 @@ describe('Windows host Git environment readiness', () => { const waitUntilReady = vi.fn(() => new Promise(() => {})) const child = createMockChildProcess(1234) spawnMock.mockReturnValue(child) + execFileMock.mockImplementation((_cmd, _args, _options, callback) => { + callback(Object.assign(new Error('missing SSH config'), { code: 1 }), '', '') + return child + }) configureWindowsHostGitEnvironmentReadiness(waitUntilReady) await expect( diff --git a/src/main/git/runner-wsl-direct-read.test.ts b/src/main/git/runner-wsl-direct-read.test.ts index f57f18f2df0..781f44b533c 100644 --- a/src/main/git/runner-wsl-direct-read.test.ts +++ b/src/main/git/runner-wsl-direct-read.test.ts @@ -19,7 +19,7 @@ vi.mock('../diagnostics/main-thread-churn-probe', () => ({ recordSubprocessSpawn import { getBranchConflictKind } from './repo-branch-conflict' import { pendingWslDirectGitReadEnvironment } from './command-runner/git-command-resolution' -import { gitExecFileAsync, gitSpawn, gitStreamStdout } from './runner' +import { gitExecFileAsync, gitExecFileAsyncBuffer, gitSpawn, gitStreamStdout } from './runner' import { GitAdmissionScheduler, _resetGitAdmissionForTests, @@ -616,7 +616,7 @@ describe('WSL direct Git reads', () => { await expect( getBranchConflictKind(String.raw`\\wsl.localhost\Ubuntu\repo`, 'new-feature') ).resolves.toBeNull() - expect(execFileMock).toHaveBeenCalledTimes(2) + expect(execFileMock).toHaveBeenCalledTimes(3) expect(execFileMock.mock.calls[0]?.[1]).toContain('--quiet') }) }) @@ -825,38 +825,41 @@ describe('WSL direct Git reads', () => { }) }) - it('aborts an async Git call while linked-worktree discovery remains pending', async () => { - await withPlatform('win32', async () => { - let releaseStat: (() => void) | undefined - const delayedStat = new Promise((resolve) => { - releaseStat = resolve - }) - const fileSystem: WslLinkedWorktreeRoutingFileSystem = { - stat: vi.fn(async () => { - await delayedStat - return { isDirectory: () => false, isFile: () => true } - }), - readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') - } - const discovery = prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, DISTRO, { - platform: 'win32', - fileSystem - }) - const controller = new AbortController() + it.each([gitExecFileAsync, gitExecFileAsyncBuffer])( + 'aborts an async Git read while linked-worktree discovery remains pending (%s)', + async (readGit) => { + await withPlatform('win32', async () => { + let releaseStat: (() => void) | undefined + const delayedStat = new Promise((resolve) => { + releaseStat = resolve + }) + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(async () => { + await delayedStat + return { isDirectory: () => false, isFile: () => true } + }), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + const discovery = prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, DISTRO, { + platform: 'win32', + fileSystem + }) + const controller = new AbortController() - const command = gitExecFileAsync(['status', '--short'], { - cwd: String.raw`C:\repo`, - wslDistro: DISTRO, - signal: controller.signal - }) - controller.abort() + const command = readGit(['show', 'HEAD:file'], { + cwd: String.raw`C:\repo`, + wslDistro: DISTRO, + signal: controller.signal + }) + controller.abort() - await expect(command).rejects.toMatchObject({ name: 'AbortError' }) - expect(execFileMock).not.toHaveBeenCalled() - releaseStat?.() - await expect(discovery).resolves.toBe(true) - }) - }) + await expect(command).rejects.toMatchObject({ name: 'AbortError' }) + expect(execFileMock).not.toHaveBeenCalled() + releaseStat?.() + await expect(discovery).resolves.toBe(true) + }) + } + ) it('keeps gitSpawn cache-only when a linked-worktree route expires', async () => { await withPlatform('win32', async () => { diff --git a/src/main/git/runner-wsl-login-shell-capture.test.ts b/src/main/git/runner-wsl-login-shell-capture.test.ts index cbf7dfdd067..4c9033b9b4b 100644 --- a/src/main/git/runner-wsl-login-shell-capture.test.ts +++ b/src/main/git/runner-wsl-login-shell-capture.test.ts @@ -96,7 +96,7 @@ describe('WSL login-shell reads are fenced', () => { execFileMock.mockImplementation((_command, args, _options, callback) => { const script = String(args.at(-1)) const nonce = /__ORCA_WSL_CAPTURE_BEGIN_([^_]+)__/.exec(script)?.[1] ?? '' - const stdout = script.includes('core.sshCommand') + const stdout = script.includes('sshcommand') ? `${BANNER}__ORCA_WSL_CAPTURE_BEGIN_${nonce}__${configured}__ORCA_WSL_CAPTURE_END_${nonce}__` : 'ok' queueMicrotask(() => callback?.(null, stdout, '')) @@ -106,7 +106,7 @@ describe('WSL login-shell reads are fenced', () => { function sshCommandFromLastGitCall(): string | undefined { const gitCall = execFileMock.mock.calls.findLast( - (call) => !String(call[1]?.at(-1)).includes('core.sshCommand') + (call) => !String(call[1]?.at(-1)).includes('sshcommand') ) return (gitCall?.[2] as { env?: NodeJS.ProcessEnv } | undefined)?.env?.GIT_SSH_COMMAND } @@ -126,7 +126,7 @@ describe('WSL login-shell reads are fenced', () => { }) it('still honors a genuinely configured core.sshCommand', async () => { - respondToSshPolicyProbe('ssh -i /home/alice/.ssh/id_ed25519\n') + respondToSshPolicyProbe('core.sshcommand\nssh -i /home/alice/.ssh/id_ed25519\0') await gitExecFileAsync(['fetch', 'origin'], { cwd: WSL_CWD, diff --git a/src/main/git/runner-wsl-network-ssh-policy.test.ts b/src/main/git/runner-wsl-network-ssh-policy.test.ts new file mode 100644 index 00000000000..695e57d419f --- /dev/null +++ b/src/main/git/runner-wsl-network-ssh-policy.test.ts @@ -0,0 +1,218 @@ +import { EventEmitter } from 'node:events' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() })) +vi.mock('node:child_process', () => ({ + execFile: execFileMock, + execFileSync: vi.fn(), + spawn: vi.fn() +})) + +import { gitExecFileAsync } from './runner' +import { + GitAdmissionScheduler, + _resetGitAdmissionForTests +} from './command-runner/git-subprocess-admission' + +const originalPlatform = process.platform +const WSL_CWD = String.raw`\\wsl.localhost\Ubuntu\home\alice\repo` +const SSH_COMMAND = 'ssh -F /home/alice/.ssh/config' +const FETCH_ARGS = ['fetch', '--no-write-fetch-head', 'origin'] + +class ShellChild extends EventEmitter { + readonly pid = 0 + readonly stdout = new EventEmitter() + readonly stderr = new EventEmitter() + readonly kill = vi.fn() +} + +type ShellCall = { script: string; env: NodeJS.ProcessEnv; child: ShellChild } + +function installShell( + options: { delayMs?: number; variant?: string; configError?: Error } = {} +): ShellCall[] { + const calls: ShellCall[] = [] + execFileMock.mockImplementation((_program, args, spawnOptions, callback) => { + const child = new ShellChild() + const script = String(args.at(-1)) + calls.push({ script, env: spawnOptions.env, child }) + let closed = false + const close = () => { + if (closed) { + return + } + closed = true + child.emit('close', 0, null) + } + const reply = setTimeout(() => { + const isProbe = script.includes("'config'") + const payload = isProbe + ? `core.sshcommand\n${SSH_COMMAND}\0ssh.variant\n${options.variant ?? 'ssh'}\0` + : 'fetch-ok' + const nonce = /__ORCA_WSL_CAPTURE_BEGIN_([^_]+)__/.exec(script)?.[1] + const stdout = nonce + ? `profile banner\n__ORCA_WSL_CAPTURE_BEGIN_${nonce}__${payload}__ORCA_WSL_CAPTURE_END_${nonce}__` + : payload + callback(isProbe ? (options.configError ?? null) : null, stdout, '') + close() + }, options.delayMs ?? 3000) + child.kill.mockImplementation(() => { + clearTimeout(reply) + queueMicrotask(close) + return true + }) + return child + }) + return calls +} + +function fetch(options: { timeout?: number; signal?: AbortSignal; env?: NodeJS.ProcessEnv } = {}) { + return gitExecFileAsync(FETCH_ARGS, { + cwd: WSL_CWD, + wslDistro: 'Ubuntu', + env: {}, + ...options + }) +} + +function networkCalls(calls: ShellCall[]): ShellCall[] { + return calls.filter(({ script }) => script.includes("'fetch'")) +} + +beforeEach(() => { + vi.useFakeTimers() + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + execFileMock.mockReset() + _resetGitAdmissionForTests(new GitAdmissionScheduler({ generalCap: 1, generalHeadroom: 0 })) +}) + +afterEach(() => { + vi.useRealTimers() + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + _resetGitAdmissionForTests() +}) + +describe('WSL network Git SSH policy startup', () => { + it('allows a three-second shell startup and preserves global config arguments', async () => { + const calls = installShell() + const args = ['-c', `core.sshCommand=${SSH_COMMAND}`, ...FETCH_ARGS] + const pending = gitExecFileAsync(args, { cwd: WSL_CWD, wslDistro: 'Ubuntu', env: {} }) + + await vi.advanceTimersByTimeAsync(6500) + await expect(pending).resolves.toEqual({ stdout: 'fetch-ok', stderr: '' }) + expect(calls).toHaveLength(2) + const probeScript = calls[0].script + expect(probeScript).toContain(`core.sshCommand=${SSH_COMMAND}`) + expect(probeScript.indexOf("'-c'")).toBeGreaterThan(-1) + expect(probeScript.indexOf("'-c'")).toBeLessThan(probeScript.indexOf('core.sshCommand=')) + expect(probeScript.indexOf('core.sshCommand=')).toBeLessThan(probeScript.indexOf("'config'")) + expect(calls[0].script).toContain('__ORCA_WSL_CAPTURE_BEGIN_') + expect(calls[0].env.GIT_SSH_COMMAND).toBeUndefined() + expect(networkCalls(calls)[0].env.GIT_SSH_COMMAND).toBe(`${SSH_COMMAND} -o BatchMode=yes`) + expect(networkCalls(calls)[0].env.WSLENV?.split(':')).toContain('GIT_SSH_COMMAND') + }) + + it('honors an explicit shorter timeout before the network command starts', async () => { + const calls = installShell() + const pending = fetch({ timeout: 1000 }).catch((error) => error) + + await vi.advanceTimersByTimeAsync(1200) + expect(await pending).toMatchObject({ message: 'wsl.exe timed out.' }) + expect(calls).toHaveLength(1) + expect(calls[0].child.kill).toHaveBeenCalledOnce() + expect(networkCalls(calls)).toHaveLength(0) + }) + + it.each([30_000, 0])('keeps the probe bounded with explicit timeout %s', async (timeout) => { + const calls = installShell({ delayMs: 11_000 }) + const pending = fetch({ timeout }).catch((error) => error) + + await vi.advanceTimersByTimeAsync(10_500) + expect(await pending).toMatchObject({ message: 'wsl.exe timed out.' }) + expect(calls[0].child.kill).toHaveBeenCalledOnce() + expect(networkCalls(calls)).toHaveLength(0) + }) + + it.each([undefined, 0])('keeps the native probe deadline with timeout %s', async (timeout) => { + const calls = installShell() + const pending = gitExecFileAsync(FETCH_ARGS, { cwd: 'C:\\repo', env: {}, timeout }).catch( + (error) => error + ) + + await vi.advanceTimersByTimeAsync(2700) + expect(await pending).toMatchObject({ message: 'git timed out.' }) + expect(calls).toHaveLength(1) + expect(calls[0].child.kill).toHaveBeenCalledOnce() + }) + + it('uses generic batch mode only when config reports a missing value', async () => { + const calls = installShell({ configError: Object.assign(new Error('missing'), { code: 1 }) }) + const pending = fetch() + + await vi.advanceTimersByTimeAsync(6500) + await expect(pending).resolves.toMatchObject({ stdout: 'fetch-ok' }) + expect(networkCalls(calls)[0].env.GIT_SSH_COMMAND).toBe('ssh -o BatchMode=yes') + }) + + it.each([128, 'ETIMEDOUT', 'ENOBUFS'])( + 'keeps configuration failure %s closed after a slow startup', + async (code) => { + const configError = Object.assign(new Error('WSL configuration unavailable'), { code }) + const calls = installShell({ configError }) + const pending = fetch().catch((error) => error) + + await vi.advanceTimersByTimeAsync(3500) + expect(await pending).toBe(configError) + expect(calls).toHaveLength(1) + expect(networkCalls(calls)).toHaveLength(0) + } + ) + + it.each(['simple', 'putty'])('preserves configured %s variant behavior', async (variant) => { + const calls = installShell({ variant }) + const pending = fetch() + + await vi.advanceTimersByTimeAsync(6500) + await expect(pending).resolves.toMatchObject({ stdout: 'fetch-ok' }) + expect(networkCalls(calls)).toHaveLength(1) + expect(networkCalls(calls)[0].env.GIT_SSH_COMMAND).toBeUndefined() + }) + + it('aborts the slow probe and releases admission for the next attempt', async () => { + const calls = installShell() + const controller = new AbortController() + const pending = fetch({ signal: controller.signal }).catch((error) => error) + + await vi.advanceTimersByTimeAsync(1000) + controller.abort() + await vi.advanceTimersByTimeAsync(0) + expect(await pending).toMatchObject({ name: 'AbortError' }) + expect(calls[0].child.kill).toHaveBeenCalledOnce() + expect(networkCalls(calls)).toHaveLength(0) + + const retry = fetch() + await vi.advanceTimersByTimeAsync(6500) + await expect(retry).resolves.toMatchObject({ stdout: 'fetch-ok' }) + expect(networkCalls(calls)).toHaveLength(1) + }) + + it('does not spawn a probe for an already-aborted operation', async () => { + const calls = installShell() + const controller = new AbortController() + controller.abort() + + await expect(fetch({ signal: controller.signal })).rejects.toMatchObject({ name: 'AbortError' }) + expect(calls).toHaveLength(0) + }) + + it('preserves explicitly forwarded SSH environment and skips the probe', async () => { + const calls = installShell() + const pending = fetch({ env: { GIT_SSH_COMMAND: SSH_COMMAND, WSLENV: 'GIT_SSH_COMMAND' } }) + + await vi.advanceTimersByTimeAsync(3500) + await expect(pending).resolves.toMatchObject({ stdout: 'fetch-ok' }) + expect(calls).toHaveLength(1) + expect(networkCalls(calls)[0].env.GIT_SSH_COMMAND).toBe(SSH_COMMAND) + expect(networkCalls(calls)[0].env.WSLENV?.split(':')).toContain('GIT_SSH_COMMAND') + }) +}) diff --git a/src/main/git/source-control/blob-absence-real-git.test.ts b/src/main/git/source-control/blob-absence-real-git.test.ts new file mode 100644 index 00000000000..0bc27cc2083 --- /dev/null +++ b/src/main/git/source-control/blob-absence-real-git.test.ts @@ -0,0 +1,135 @@ +import { readFile, unlink, utimes, writeFile } from 'node:fs/promises' +import * as path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { runProcess } from '../../../shared/child-process/run-process' +import { gitCommit, gitInit, type MockDispatcher } from '../../../relay/git-handler-test-setup' +import { + createGitHandlerRelay, + createGitTempDir, + removeGitTempDir +} from '../../../relay/git-handler-test-harness' +import type { GitHandler } from '../../../relay/git-handler' +import { isMissingGitBlobPath } from '../../../shared/git-blob-absence' +import { readGitBlobAtIndexPath } from './git-blob-read' +import { getDiff } from './file-diff' +import { invalidateGitReadCaches, settledDiffCache } from './git-read-cache-invalidation' +import { readWorktreeDiffStamp } from './worktree-diff-stamp' + +describe('real Git missing paths and failed blob reads', () => { + let repo: string + let dispatcher: MockDispatcher + let handler: GitHandler + + beforeEach(() => { + repo = createGitTempDir() + ;({ dispatcher, handler } = createGitHandlerRelay()) + gitInit(repo) + invalidateGitReadCaches() + }) + + afterEach(async () => { + handler.dispose() + invalidateGitReadCaches() + await removeGitTempDir(repo) + }) + + async function git(args: string[], input?: string): Promise { + const result = await runProcess({ program: 'git', args, cwd: repo, input }) + expect(result.code, result.stderr).toBe(0) + return result.stdout + } + + function diff(host: 'native' | 'relay', staged = false): Promise { + return host === 'native' + ? getDiff(repo, 'file.txt', staged) + : dispatcher.callRequest('git.diff', { worktreePath: repo, filePath: 'file.txt', staged }) + } + + it.each(['native', 'relay'] as const)( + 'does not substitute HEAD for a corrupt index blob on %s', + async (host) => { + await writeFile(path.join(repo, 'file.txt'), 'committed\n') + gitCommit(repo, 'initial') + await writeFile(path.join(repo, 'file.txt'), 'staged\n') + await git(['add', 'file.txt']) + const oid = (await git(['rev-parse', ':file.txt'])).trim() + await writeFile(path.join(repo, 'file.txt'), 'working\n') + const old = new Date(Date.now() - 10_000) + await Promise.all( + ['.git/index', 'file.txt'].map((file) => utimes(path.join(repo, file), old, old)) + ) + const before = await readWorktreeDiffStamp(repo, 'file.txt', true) + await unlink(path.join(repo, '.git', 'objects', oid.slice(0, 2), oid.slice(2))) + expect(await readGitBlobAtIndexPath(repo, 'file.txt')).toMatchObject({ + exists: false, + failed: true + }) + + expect(await diff(host)).toMatchObject({ originalContent: '', modifiedContent: 'working\n' }) + if (host === 'native') { + expect(settledDiffCache.stats().entries).toBe(0) + } + await git(['hash-object', '-w', '--stdin'], 'staged\n') + const after = await readWorktreeDiffStamp(repo, 'file.txt', true) + expect(after?.value).toBe(before?.value) + + expect(await diff(host)).toMatchObject({ + originalContent: 'staged\n', + modifiedContent: 'working\n' + }) + if (host === 'native') { + expect(settledDiffCache.stats().entries).toBe(1) + } + } + ) + + it.each(['native', 'relay'] as const)( + 'retains added paths on an unborn branch on %s', + async (host) => { + await writeFile(path.join(repo, 'file.txt'), 'new file\n') + await git(['add', 'file.txt']) + expect(await diff(host, true)).toMatchObject({ + originalContent: '', + modifiedContent: 'new file\n' + }) + } + ) + + it.each(['native', 'relay'] as const)( + 'retains additions absent from a valid HEAD tree on %s', + async (host) => { + await writeFile(path.join(repo, 'seed.txt'), 'seed\n') + gitCommit(repo, 'initial') + await writeFile(path.join(repo, 'file.txt'), 'new file\n') + await git(['add', 'file.txt']) + expect(await diff(host, true)).toMatchObject({ + originalContent: '', + modifiedContent: 'new file\n' + }) + } + ) + + it('recognizes actual index and tree absence without accepting corrupt objects', async () => { + await writeFile(path.join(repo, 'seed.txt'), 'seed\n') + gitCommit(repo, 'initial') + const names = ['file.txt', ...(process.platform === 'win32' ? [] : ["quote' and\nnewline.txt"])] + for (const filePath of names) { + for (const presentOnDisk of [false, true]) { + if (presentOnDisk) { + await writeFile(path.join(repo, filePath), 'untracked\n') + } + for (const oid of [undefined, 'HEAD']) { + const result = await runProcess({ + program: 'git', + args: ['show', '--end-of-options', `${oid ?? ''}:${filePath}`], + cwd: repo + }) + expect( + isMissingGitBlobPath({ code: result.code, stderr: result.stderr }, filePath, oid) + ).toBe(true) + } + } + } + expect(await readFile(path.join(repo, 'seed.txt'), 'utf8')).toBe('seed\n') + }) +}) diff --git a/src/main/git/source-control/branch-change-entries.ts b/src/main/git/source-control/branch-change-entries.ts index 932bc9cba22..362a2369361 100644 --- a/src/main/git/source-control/branch-change-entries.ts +++ b/src/main/git/source-control/branch-change-entries.ts @@ -1,65 +1,17 @@ import type { GitBranchChangeEntry } from '../../../shared/git-diff-compare-types' -import type { GitBranchChangeStatus } from '../../../shared/git-status-types' -import { parseNumstat } from '../../../shared/git-uncommitted-line-stats' -import { decodeGitCQuotedPath } from '../../../shared/git-cquoted-path' +import { gitChangeListArgs, parseGitChangeList } from '../../../shared/git-change-list' import type { GitRuntimeOptions } from '../git-runtime-options' import { gitOptionsForWorktree } from '../git-runtime-options' import { gitExecFileAsync } from '../runner' import { MAX_GIT_SHOW_BYTES } from './git-show-max-bytes' -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 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 + return loadCommitChanges(worktreePath, mergeBase, headOid, options) } export async function loadCommitChanges( @@ -68,78 +20,9 @@ export async function loadCommitChanges( 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 = { + const { stdout } = await gitExecFileAsync(gitChangeListArgs(parentOid, commitOid), { ...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 -} - -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 } + }) + return parseGitChangeList(stdout) } diff --git a/src/main/git/source-control/bulk-pathspec-command-line-budget.test.ts b/src/main/git/source-control/bulk-pathspec-command-line-budget.test.ts index 39c4135aade..4b8a7a7aa94 100644 --- a/src/main/git/source-control/bulk-pathspec-command-line-budget.test.ts +++ b/src/main/git/source-control/bulk-pathspec-command-line-budget.test.ts @@ -5,11 +5,13 @@ import { } from '../../../shared/windows-command-line-budget' import { resolveGitCommandWithoutProbe } from '../command-runner/git-command-resolution' -const gitExecFileAsync = vi.fn(async () => ({ stdout: '', stderr: '' })) +const gitExecFileAsync = vi.fn(async (_args: string[], _options: { stdin?: string }) => ({ + stdout: '', + stderr: '' +})) vi.mock('../runner', () => ({ - gitExecFileAsync: (...args: unknown[]) => - (gitExecFileAsync as unknown as (...a: unknown[]) => Promise<{ stdout: string }>)(...args) + gitExecFileAsync: (args: string[], options: { stdin?: string }) => gitExecFileAsync(args, options) })) vi.mock('./git-read-cache-invalidation', () => ({ invalidateGitReadCaches: vi.fn() })) vi.mock('../../../shared/git-discard-path-safety', () => ({ @@ -49,7 +51,7 @@ function realisticChangedPaths(count: number): string[] { } function capturedInvocations(): string[][] { - return gitExecFileAsync.mock.calls.map((call) => (call as unknown as [string[]])[0]) + return gitExecFileAsync.mock.calls.map(([args]) => args) } describe('bulk pathspec command-line budget', () => { @@ -78,13 +80,15 @@ describe('bulk pathspec command-line budget', () => { expect(Math.max(...lengths)).toBeLessThanOrEqual(MAX_COMMAND_LINE_CHARS) }) - it('stages every path exactly once, in order, across the chunks', async () => { + it('stages every path exactly once, in order, through stdin', async () => { const { bulkStageFiles } = await import('./staging') const filePaths = realisticChangedPaths(250) await bulkStageFiles(WSL_WORKTREE, filePaths, { wslDistro: WSL_DISTRO }) - const staged = capturedInvocations().flatMap((args) => args.slice(args.indexOf('--') + 1)) + const staged = gitExecFileAsync.mock.calls.flatMap( + ([, options]) => options.stdin?.split('\0').filter(Boolean) ?? [] + ) expect(staged).toEqual(filePaths.map((filePath) => `:(literal)${filePath}`)) }) @@ -97,17 +101,14 @@ describe('bulk pathspec command-line budget', () => { expect(Math.max(...lengths)).toBeLessThanOrEqual(MAX_COMMAND_LINE_CHARS) }) - it('never emits a pathspec-free chunk, which would widen `clean -ffdx` to the worktree', async () => { + it('does not spawn an empty WSL bulk stage', async () => { const { bulkStageFiles } = await import('./staging') - await bulkStageFiles(WSL_WORKTREE, realisticChangedPaths(300), { wslDistro: WSL_DISTRO }) - - for (const args of capturedInvocations()) { - expect(args.slice(args.indexOf('--') + 1).length).toBeGreaterThan(0) - } + await bulkStageFiles(WSL_WORKTREE, [], { wslDistro: WSL_DISTRO }) + expect(capturedInvocations()).toEqual([]) }) - it('splits a WSL bulk discard of tracked paths into spawnable restores', async () => { + it('discards tracked paths with one spawnable WSL restore', async () => { const filePaths = realisticChangedPaths(120) gitExecFileAsync.mockImplementation(async () => ({ stdout: filePaths.join('\0'), stderr: '' })) const { bulkDiscardChanges } = await import('./discard-changes') @@ -115,7 +116,7 @@ describe('bulk pathspec command-line budget', () => { await bulkDiscardChanges(WSL_WORKTREE, filePaths, { wslDistro: WSL_DISTRO }) const restores = capturedInvocations().filter((args) => args[0] === 'restore') - expect(restores.length).toBeGreaterThan(1) + expect(restores).toHaveLength(1) for (const args of restores) { expect(finishedCommandLineLength(args, WSL_DISTRO)).toBeLessThanOrEqual( MAX_COMMAND_LINE_CHARS @@ -141,24 +142,28 @@ describe('bulk pathspec command-line budget', () => { } }) - it('ships a single over-budget pathspec alone rather than dropping it', async () => { + it('keeps an over-budget pathspec off argv without dropping it', async () => { const { bulkStageFiles } = await import('./staging') const hugePath = `src/${'nested-directory/'.repeat(700)}Component.tsx` await bulkStageFiles(WSL_WORKTREE, [hugePath, 'src/app.tsx'], { wslDistro: WSL_DISTRO }) const invocations = capturedInvocations() - expect(invocations).toHaveLength(2) - expect(invocations[0]).toEqual(['add', '--', `:(literal)${hugePath}`]) - expect(invocations[1]).toEqual(['add', '--', ':(literal)src/app.tsx']) + expect(invocations).toEqual([['add', '--pathspec-from-file=-', '--pathspec-file-nul']]) + expect(gitExecFileAsync.mock.calls[0][1].stdin).toBe( + `:(literal)${hugePath}\0:(literal)src/app.tsx\0` + ) }) it('packs chunks to the budget instead of splitting timidly', async () => { - const { bulkStageFiles } = await import('./staging') - - await bulkStageFiles(WSL_WORKTREE, realisticChangedPaths(100), { wslDistro: WSL_DISTRO }) - - const lengths = capturedInvocations().map((args) => finishedCommandLineLength(args, WSL_DISTRO)) + const { bulkPathspecCommands } = await import('./git-pathspec') + const commands = bulkPathspecCommands( + ['ls-files', '-z', '--'], + realisticChangedPaths(100), + WSL_WORKTREE, + { wslDistro: WSL_DISTRO } + ) + const lengths = commands.map((args) => finishedCommandLineLength(args, WSL_DISTRO)) // Every chunk but the last is filled to within one pathspec of the cap. expect(Math.min(...lengths.slice(0, -1))).toBeGreaterThan(MAX_COMMAND_LINE_CHARS * 0.9) }) diff --git a/src/main/git/source-control/bulk-pathspec-stdin.test.ts b/src/main/git/source-control/bulk-pathspec-stdin.test.ts new file mode 100644 index 00000000000..b08cc81d3a6 --- /dev/null +++ b/src/main/git/source-control/bulk-pathspec-stdin.test.ts @@ -0,0 +1,103 @@ +import { readFile, writeFile } from 'node:fs/promises' +import * as path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { runProcess } from '../../../shared/child-process/run-process' +import { gitCommit, gitInit, type MockDispatcher } from '../../../relay/git-handler-test-setup' +import { + createGitHandlerRelay, + createGitTempDir, + removeGitTempDir +} from '../../../relay/git-handler-test-harness' +import type { GitHandler } from '../../../relay/git-handler' +import { bulkStageFiles, bulkUnstageFiles } from './staging' +import { bulkDiscardChanges } from './discard-changes' + +describe('bulk pathspec stdin on execution hosts', () => { + let repo: string + let dispatcher: MockDispatcher + let handler: GitHandler + + beforeEach(() => { + repo = createGitTempDir() + ;({ dispatcher, handler } = createGitHandlerRelay()) + gitInit(repo) + }) + + afterEach(async () => { + handler.dispose() + await removeGitTempDir(repo) + }) + + async function changedFiles(staged: boolean): Promise { + const result = await runProcess({ + program: 'git', + args: ['diff', ...(staged ? ['--cached'] : []), '--name-only', '-z'], + cwd: repo + }) + expect(result.code).toBe(0) + return result.stdout.split('\0').filter(Boolean).sort() + } + + function mutate( + host: 'native' | 'relay', + action: 'stage' | 'unstage' | 'discard', + filePaths: string[] + ): Promise { + if (host === 'relay') { + const methods = { + stage: 'git.bulkStage', + unstage: 'git.bulkUnstage', + discard: 'git.bulkDiscard' + } + return dispatcher.callRequest(methods[action], { worktreePath: repo, filePaths }) + } + const operations = { + stage: bulkStageFiles, + unstage: bulkUnstageFiles, + discard: bulkDiscardChanges + } + return operations[action](repo, filePaths) + } + + it.each(['native', 'relay'] as const)( + 'stages, unstages and discards over 100 literal paths on %s', + async (host) => { + const selected = Array.from({ length: 105 }, (_, index) => `file-${index}.txt`) + selected.push('[k]eep.log', 'space name.txt', '-option.txt') + if (process.platform !== 'win32') { + selected.push('line\nname.txt', ':(magic).txt', 'back\\slash.txt') + } + const allPaths = [...selected, 'keep.log'] + await Promise.all( + allPaths.map((filePath) => writeFile(path.join(repo, filePath), 'original\n')) + ) + gitCommit(repo, 'initial') + await Promise.all( + allPaths.map((filePath) => writeFile(path.join(repo, filePath), 'modified\n')) + ) + + await mutate(host, 'stage', []) + expect(await changedFiles(true)).toEqual([]) + await mutate(host, 'stage', selected) + expect(await changedFiles(true)).toEqual([...selected].sort()) + await mutate(host, 'unstage', []) + expect(await changedFiles(true)).toEqual([...selected].sort()) + await mutate(host, 'unstage', selected) + expect(await changedFiles(true)).toEqual([]) + await mutate(host, 'discard', []) + expect(await changedFiles(false)).toEqual([...allPaths].sort()) + await mutate(host, 'discard', selected) + expect(await changedFiles(false)).toEqual(['keep.log']) + expect(await readFile(path.join(repo, 'keep.log'), 'utf8')).toBe('modified\n') + } + ) + + it.each(['native', 'relay'] as const)('rejects NUL path injection on %s', async (host) => { + await writeFile(path.join(repo, 'one.txt'), 'original\n') + gitCommit(repo, 'initial') + await writeFile(path.join(repo, 'one.txt'), 'modified\n') + + await expect(mutate(host, 'stage', ['one.txt\0:(top)**'])).rejects.toThrow('NUL') + expect(await changedFiles(true)).toEqual([]) + }) +}) diff --git a/src/main/git/source-control/discard-changes.ts b/src/main/git/source-control/discard-changes.ts index 0cf918f729c..b9bfd83ef1f 100644 --- a/src/main/git/source-control/discard-changes.ts +++ b/src/main/git/source-control/discard-changes.ts @@ -9,6 +9,7 @@ import { gitOptionsForWorktree } from '../git-runtime-options' import { gitExecFileAsync } from '../runner' import { invalidateGitReadCaches } from './git-read-cache-invalidation' import { bulkPathspecCommands, literalPathspec } from './git-pathspec' +import { encodeGitPathspecs } from '../../../shared/git-pathspec-stdin' /** * Discard working tree changes for a file. @@ -40,12 +41,9 @@ export async function discardChanges( } if (tracked) { - await gitExecFileAsync( - ['restore', '--worktree', '--source=HEAD', '--', literalPathspec(filePath, options)], - { - ...gitOptionsForWorktree(worktreePath, options) - } - ) + await gitExecFileAsync(['restore', '--worktree', '--', literalPathspec(filePath, options)], { + ...gitOptionsForWorktree(worktreePath, options) + }) return } @@ -124,15 +122,18 @@ export async function bulkDiscardChanges( untrackedPaths, (targetPaths) => cleanUntrackedPaths(worktreePath, targetPaths, options), async () => { - const commands = bulkPathspecCommands( - ['restore', '--worktree', '--source=HEAD', '--'], - trackedPaths, - worktreePath, - options - ) - for (const args of commands) { - await gitExecFileAsync(args, { ...gitOptionsForWorktree(worktreePath, options) }) + if (trackedPaths.length === 0) { + return } + await gitExecFileAsync( + ['restore', '--worktree', '--pathspec-from-file=-', '--pathspec-file-nul'], + { + ...gitOptionsForWorktree(worktreePath, options), + stdin: encodeGitPathspecs( + trackedPaths.map((filePath) => literalPathspec(filePath, options)) + ) + } + ) } ) } finally { diff --git a/src/main/git/source-control/effective-upstream-status-probe.ts b/src/main/git/source-control/effective-upstream-status-probe.ts index c9483dcf681..d00350dd19f 100644 --- a/src/main/git/source-control/effective-upstream-status-probe.ts +++ b/src/main/git/source-control/effective-upstream-status-probe.ts @@ -4,7 +4,6 @@ import { getGitUpstreamStatusForUpstreamName, splitRemoteBranchName } from '../../../shared/git-effective-upstream' -import { createGitConfigSnapshotRunner } from '../../../shared/git-config-snapshot-runner' import type { GitRuntimeOptions } from '../git-runtime-options' import { gitReadOptionsForWorktree } from '../git-runtime-options' import { gitExecFileAsync } from '../runner' @@ -126,14 +125,11 @@ async function probeEffectiveUpstreamStatus( options: GitRuntimeOptions = {} ): Promise<{ status: GitUpstreamStatus; probedSameNameOriginRef: boolean }> { let probedSameNameOriginRef = false - const snapshotRunner = createGitConfigSnapshotRunner((args) => - gitExecFileAsync(args, gitReadOptionsForWorktree(worktreePath, options)) - ) const status = await getEffectiveGitUpstreamStatus((args) => { if (args[0] === 'rev-parse' && args.includes(`refs/remotes/origin/${branchName}`)) { probedSameNameOriginRef = true } - return snapshotRunner(args) + return gitExecFileAsync(args, gitReadOptionsForWorktree(worktreePath, options)) }) return { status, probedSameNameOriginRef } } diff --git a/src/main/git/source-control/file-diff.ts b/src/main/git/source-control/file-diff.ts index a16fe079cfd..2d870d95c6e 100644 --- a/src/main/git/source-control/file-diff.ts +++ b/src/main/git/source-control/file-diff.ts @@ -170,7 +170,7 @@ async function loadDiff( originalIsBinary = leftBlob.isBinary modifiedContent = rightBlob.content modifiedIsBinary = rightBlob.isBinary - modifiedDeleted = !rightBlob.exists + modifiedDeleted = !rightBlob.exists && !rightBlob.failed readFailed = leftBlob.failed === true || rightBlob.failed === true } else { // The left chain (index→HEAD) is sequential within itself, but the working diff --git a/src/main/git/source-control/git-blob-read.ts b/src/main/git/source-control/git-blob-read.ts index f25a80eedbb..e3490b0f744 100644 --- a/src/main/git/source-control/git-blob-read.ts +++ b/src/main/git/source-control/git-blob-read.ts @@ -1,6 +1,7 @@ import { readFile, stat } from 'node:fs/promises' import * as path from 'node:path' import { isBinaryBuffer } from '../../../shared/binary-buffer' +import { isMissingGitBlobPath } from '../../../shared/git-blob-absence' import type { GitRuntimeOptions } from '../git-runtime-options' import { gitReadOptionsForWorktree } from '../git-runtime-options' import { gitExecFileAsyncBuffer } from '../runner' @@ -20,28 +21,17 @@ export type GitBlobReadResult = { failed?: boolean } -/** - * Tell "Git ran and said the path is not there" apart from "the read never got - * an answer". Git exits 128 for a missing path in a tree or the index; a WSL - * relay that never reached Git exits with anything else, or with a spawn errno. - */ -function isProvenAbsentError(error: unknown): boolean { - return (error as { code?: unknown } | null)?.code === 128 -} - export async function readUnstagedLeftBlob( worktreePath: string, filePath: string, options: GitRuntimeOptions = {} ): Promise { const indexBlob = await readGitBlobAtIndexPath(worktreePath, filePath, options) - if (indexBlob.exists) { + if (indexBlob.exists || indexBlob.failed) { return indexBlob } - const headBlob = await readGitBlobAtOidPath(worktreePath, 'HEAD', filePath, options) - // Why: if the index read never got an answer, falling back to HEAD is a guess, not a proof. - return indexBlob.failed ? { ...headBlob, failed: true } : headBlob + return readGitBlobAtOidPath(worktreePath, 'HEAD', filePath, options) } export async function readGitBlobAtIndexPath( @@ -62,7 +52,12 @@ export async function readGitBlobAtIndexPath( if (isMaxBufferOverflowError(error)) { return { content: '', isBinary: true, exists: true } } - return { content: '', isBinary: false, exists: false, failed: !isProvenAbsentError(error) } + return { + content: '', + isBinary: false, + exists: false, + failed: !isMissingGitBlobPath(error, gitPath) + } } } @@ -88,7 +83,12 @@ export async function readGitBlobAtOidPath( if (isMaxBufferOverflowError(error)) { return { content: '', isBinary: true, exists: true } } - return { content: '', isBinary: false, exists: false, failed: !isProvenAbsentError(error) } + return { + content: '', + isBinary: false, + exists: false, + failed: !isMissingGitBlobPath(error, gitPath, oid) + } } } diff --git a/src/main/git/source-control/staging-discard-index-safety.test.ts b/src/main/git/source-control/staging-discard-index-safety.test.ts new file mode 100644 index 00000000000..c5c4cbb9a4b --- /dev/null +++ b/src/main/git/source-control/staging-discard-index-safety.test.ts @@ -0,0 +1,158 @@ +import { readFile, writeFile } from 'node:fs/promises' +import * as path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { runProcess } from '../../../shared/child-process/run-process' +import { gitCommit, gitInit, type MockDispatcher } from '../../../relay/git-handler-test-setup' +import { + createGitHandlerRelay, + createGitTempDir, + removeGitTempDir +} from '../../../relay/git-handler-test-harness' +import type { GitHandler } from '../../../relay/git-handler' +import { bulkUnstageFiles, unstageFile } from './staging' +import { bulkDiscardChanges, discardChanges } from './discard-changes' + +const modes = [ + { host: 'native', bulk: false }, + { host: 'native', bulk: true }, + { host: 'relay', bulk: false }, + { host: 'relay', bulk: true } +] as const + +describe('staging and discard preserve index authority', () => { + let repo: string + let dispatcher: MockDispatcher + let handler: GitHandler + + beforeEach(() => { + repo = createGitTempDir() + ;({ dispatcher, handler } = createGitHandlerRelay()) + gitInit(repo) + }) + + afterEach(async () => { + handler.dispose() + await removeGitTempDir(repo) + }) + + async function git(args: string[], input?: string): Promise { + const result = await runProcess({ program: 'git', args, cwd: repo, input }) + expect(result.code, result.stderr).toBe(0) + return result.stdout + } + + function mutate( + mode: (typeof modes)[number], + action: 'unstage' | 'discard', + filePaths: string[] + ): Promise { + if (mode.host === 'relay') { + return dispatcher.callRequest( + `git.${mode.bulk ? (action === 'unstage' ? 'bulkUnstage' : 'bulkDiscard') : action}`, + { worktreePath: repo, ...(mode.bulk ? { filePaths } : { filePath: filePaths[0] }) } + ) + } + if (mode.bulk) { + return (action === 'unstage' ? bulkUnstageFiles : bulkDiscardChanges)(repo, filePaths) + } + return (action === 'unstage' ? unstageFile : discardChanges)(repo, filePaths[0]) + } + + it.each(modes)('preserves staged MM content during $host bulk=$bulk discard', async (mode) => { + await writeFile(path.join(repo, 'file.txt'), 'committed\n') + gitCommit(repo, 'initial') + await writeFile(path.join(repo, 'file.txt'), 'staged\n') + await git(['add', 'file.txt']) + await writeFile(path.join(repo, 'file.txt'), 'working\n') + + await mutate(mode, 'discard', ['file.txt']) + + expect(await readFile(path.join(repo, 'file.txt'), 'utf8')).toBe('staged\n') + expect(await git(['show', ':file.txt'])).toBe('staged\n') + expect(await git(['diff', '--name-only'])).toBe('') + expect(await git(['diff', '--cached', '--name-only'])).toBe('file.txt\n') + }) + + it.each(modes)('restores staged additions during $host bulk=$bulk AM discard', async (mode) => { + await writeFile(path.join(repo, 'initial.txt'), 'committed\n') + gitCommit(repo, 'initial') + await writeFile(path.join(repo, 'new.txt'), 'staged addition\n') + await git(['add', 'new.txt']) + await writeFile(path.join(repo, 'new.txt'), 'working addition\n') + + await mutate(mode, 'discard', ['new.txt']) + + expect(await readFile(path.join(repo, 'new.txt'), 'utf8')).toBe('staged addition\n') + expect(await git(['show', ':new.txt'])).toBe('staged addition\n') + expect(await git(['diff', '--name-only'])).toBe('') + }) + + it.each(modes)('unstages selected unborn paths on $host bulk=$bulk', async (mode) => { + await writeFile(path.join(repo, 'selected.txt'), 'selected\n') + await writeFile(path.join(repo, 'keep.txt'), 'keep\n') + await git(['add', '.']) + + await mutate(mode, 'unstage', ['selected.txt']) + + expect(await git(['ls-files', '-z'])).toBe('keep.txt\0') + expect(await readFile(path.join(repo, 'selected.txt'), 'utf8')).toBe('selected\n') + }) + + it.each(['native', 'relay'] as const)('unstages both rename paths on %s', async (host) => { + await writeFile(path.join(repo, 'old.txt'), 'rename content\n') + await writeFile(path.join(repo, 'keep.txt'), 'keep\n') + gitCommit(repo, 'initial') + await git(['mv', 'old.txt', 'new.txt']) + await writeFile(path.join(repo, 'keep.txt'), 'keep staged\n') + await git(['add', 'keep.txt']) + + await mutate({ host, bulk: true }, 'unstage', ['new.txt', 'old.txt']) + + expect(await git(['diff', '--cached', '--name-only'])).toBe('keep.txt\n') + expect(await git(['ls-files', '-z'])).toBe('keep.txt\0old.txt\0') + expect(await readFile(path.join(repo, 'new.txt'), 'utf8')).toBe('rename content\n') + }) + + it.each(['native', 'relay'] as const)( + 'discards a staged rename after unstaging both paths on %s', + async (host) => { + await writeFile(path.join(repo, 'old.txt'), 'rename content\n') + gitCommit(repo, 'initial') + await git(['mv', 'old.txt', 'new.txt']) + + await mutate({ host, bulk: true }, 'unstage', ['new.txt', 'old.txt']) + await mutate({ host, bulk: true }, 'discard', ['new.txt', 'old.txt']) + + expect(await git(['status', '--porcelain'])).toBe('') + expect(await readFile(path.join(repo, 'old.txt'), 'utf8')).toBe('rename content\n') + await expect(readFile(path.join(repo, 'new.txt'), 'utf8')).rejects.toMatchObject({ + code: 'ENOENT' + }) + } + ) + + it.each(modes)('rejects an unresolved conflict on $host bulk=$bulk', async (mode) => { + await writeFile(path.join(repo, 'file.txt'), 'base\n') + gitCommit(repo, 'initial') + const blobs = await Promise.all( + ['base\n', 'ours\n', 'theirs\n'].map((content) => + git(['hash-object', '-w', '--stdin'], content) + ) + ) + await git( + ['update-index', '--index-info'], + `0 ${'0'.repeat(40)}\tfile.txt\n${blobs + .map((oid, index) => `100644 ${oid.trim()} ${index + 1}\tfile.txt\n`) + .join('')}` + ) + await writeFile(path.join(repo, 'file.txt'), 'conflict resolution in progress\n') + const before = await git(['ls-files', '--unmerged', '-z']) + + await expect(mutate(mode, 'discard', ['file.txt'])).rejects.toThrow() + + expect(await readFile(path.join(repo, 'file.txt'), 'utf8')).toBe( + 'conflict resolution in progress\n' + ) + expect(await git(['ls-files', '--unmerged', '-z'])).toBe(before) + }) +}) diff --git a/src/main/git/source-control/staging.ts b/src/main/git/source-control/staging.ts index 3aa6e16bbc9..210d8f87f9b 100644 --- a/src/main/git/source-control/staging.ts +++ b/src/main/git/source-control/staging.ts @@ -2,7 +2,8 @@ import type { GitRuntimeOptions } from '../git-runtime-options' import { gitOptionsForWorktree } from '../git-runtime-options' import { gitExecFileAsync } from '../runner' import { invalidateGitReadCaches } from './git-read-cache-invalidation' -import { bulkPathspecCommands, literalPathspec } from './git-pathspec' +import { literalPathspec } from './git-pathspec' +import { encodeGitPathspecs } from '../../../shared/git-pathspec-stdin' /** * Stage a file. @@ -33,7 +34,8 @@ export async function unstageFile( ): Promise { invalidateGitReadCaches() try { - await gitExecFileAsync(['restore', '--staged', '--', literalPathspec(filePath, options)], { + // Reset treats an unborn HEAD as an empty tree, preserving the working file. + await gitExecFileAsync(['reset', '--quiet', '--', literalPathspec(filePath, options)], { ...gitOptionsForWorktree(worktreePath, options) }) } finally { @@ -42,7 +44,7 @@ export async function unstageFile( } /** - * Bulk stage files in batches to avoid E2BIG. + * Stage selected files through stdin to avoid argv limits and repeated index writes. */ export async function bulkStageFiles( worktreePath: string, @@ -54,16 +56,17 @@ export async function bulkStageFiles( return } try { - for (const args of bulkPathspecCommands(['add', '--'], filePaths, worktreePath, options)) { - await gitExecFileAsync(args, gitOptionsForWorktree(worktreePath, options)) - } + await gitExecFileAsync(['add', '--pathspec-from-file=-', '--pathspec-file-nul'], { + ...gitOptionsForWorktree(worktreePath, options), + stdin: encodeGitPathspecs(filePaths.map((filePath) => literalPathspec(filePath, options))) + }) } finally { invalidateGitReadCaches() } } /** - * Bulk unstage files in batches to avoid E2BIG. + * Unstage selected files through stdin to avoid argv limits and repeated index writes. */ export async function bulkUnstageFiles( worktreePath: string, @@ -75,15 +78,10 @@ export async function bulkUnstageFiles( return } try { - const commands = bulkPathspecCommands( - ['restore', '--staged', '--'], - filePaths, - worktreePath, - options - ) - for (const args of commands) { - await gitExecFileAsync(args, { ...gitOptionsForWorktree(worktreePath, options) }) - } + await gitExecFileAsync(['reset', '--quiet', '--pathspec-from-file=-', '--pathspec-file-nul'], { + ...gitOptionsForWorktree(worktreePath, options), + stdin: encodeGitPathspecs(filePaths.map((filePath) => literalPathspec(filePath, options))) + }) } finally { invalidateGitReadCaches() } diff --git a/src/main/git/source-control/status-line-stats.ts b/src/main/git/source-control/status-line-stats.ts index c0d7416b8d8..46437ac3ef2 100644 --- a/src/main/git/source-control/status-line-stats.ts +++ b/src/main/git/source-control/status-line-stats.ts @@ -21,6 +21,8 @@ async function runNumstat( [ '-c', 'core.quotePath=false', + '-c', + 'diff.autoRefreshIndex=false', 'diff', '-z', ...(cached ? ['--cached'] : []), diff --git a/src/main/git/source-control/submodule-status.ts b/src/main/git/source-control/submodule-status.ts index fb4ca8449e3..a97152b488b 100644 --- a/src/main/git/source-control/submodule-status.ts +++ b/src/main/git/source-control/submodule-status.ts @@ -1,13 +1,12 @@ import type { GitStatusEntry, GitStatusResult } from '../../../shared/git-status-types' import { capGitStatusEntries, resolveGitStatusLimit } from '../../../shared/git-status-limit' -import { parseNumstat } from '../../../shared/git-uncommitted-line-stats' +import { gitChangeListArgs, parseGitChangeList } from '../../../shared/git-change-list' import type { GitRuntimeOptions } from '../git-runtime-options' import { gitOptionsForWorktree } from '../git-runtime-options' import { gitExecFileAsync, gitOptionalLocksDisabledEnv } from '../runner' import type { GetStatusOptions } from './get-status-options' import { getStatus } from './status-read' import { resolveSubmoduleWorktreePath } from './submodule-paths' -import { parseBranchChangeLine } from './branch-change-entries' import { readGitlinkOidFromIndex, readGitlinkOidFromTree, @@ -83,41 +82,10 @@ async function computeSubmoduleRangeEntries( ...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 + const { stdout } = await gitExecFileAsync(gitChangeListArgs(fromOid, toOid), gitOptions) + return parseGitChangeList(stdout).map((entry) => ({ ...entry, area: 'unstaged' })) } 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/source-control/wsl-tracked-pathspec-banner.test.ts b/src/main/git/source-control/wsl-tracked-pathspec-banner.test.ts index 6404e5deca1..203e583141f 100644 --- a/src/main/git/source-control/wsl-tracked-pathspec-banner.test.ts +++ b/src/main/git/source-control/wsl-tracked-pathspec-banner.test.ts @@ -44,13 +44,20 @@ const WSL_WORKTREE = `\\\\wsl$\\${DISTRO}\\home\\emilio\\projects\\orca` const TRACKED_PATHS = ['docs/architecture.md', 'src/main/git/runner.ts'] // Stock Ubuntu writes this to *stdout* from the interactive login shell's rc. const BANNER = 'To run a command as administrator (user "root"), use "sudo ".\n\n' +const stdinEndMock = vi.fn() -type MockChild = EventEmitter & { stdout: EventEmitter; stderr: EventEmitter; kill: () => void } +type MockChild = EventEmitter & { + stdout: EventEmitter + stderr: EventEmitter + stdin: EventEmitter & { end: (input: string) => void } + kill: () => void +} function createMockChild(): MockChild { const child = new EventEmitter() as MockChild child.stdout = new EventEmitter() child.stderr = new EventEmitter() + child.stdin = Object.assign(new EventEmitter(), { end: stdinEndMock }) child.kill = vi.fn() return child } @@ -79,6 +86,7 @@ describe('WSL tracked-path listing behind a login-shell banner', () => { beforeEach(() => { resetWslGitReadEnvironmentForTests() execFileMock.mockReset() + stdinEndMock.mockReset() Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) execFileMock.mockImplementation((_command, args, _options, callback) => { const script = guestScript(args) @@ -105,8 +113,9 @@ describe('WSL tracked-path listing behind a login-shell banner', () => { expect(commandLines.filter((line) => line.includes('clean'))).toEqual([]) const restored = commandLines.filter((line) => line.includes('restore')) expect(restored.length).toBeGreaterThan(0) - for (const trackedPath of TRACKED_PATHS) { - expect(restored.some((line) => line.includes(`:(literal)${trackedPath}`))).toBe(true) - } + expect(restored.every((line) => line.includes('--pathspec-from-file=-'))).toBe(true) + expect(stdinEndMock).toHaveBeenCalledWith( + TRACKED_PATHS.map((trackedPath) => `:(literal)${trackedPath}\0`).join('') + ) }) }) diff --git a/src/main/git/status-branch-compare.test.ts b/src/main/git/status-branch-compare.test.ts index 4bd56de553c..bade46d8a6d 100644 --- a/src/main/git/status-branch-compare.test.ts +++ b/src/main/git/status-branch-compare.test.ts @@ -101,11 +101,25 @@ describe('getBranchCompare', () => { if (args[0] === 'merge-base') { return reply(responses.mergeBase, 'merge-base') } - if (args.includes('--name-status')) { - return reply(responses.nameStatus, 'diff --name-status') - } - if (args.includes('--numstat')) { - return reply(responses.numstat, 'diff --numstat') + if (args.includes('--raw')) { + const raw = + typeof responses.nameStatus === 'string' + ? responses.nameStatus + .split(/\r?\n/) + .filter(Boolean) + .map((line) => { + const [status, ...paths] = line.split('\t') + return `:100644 100644 a b ${status}\0${paths.join('\0')}\0` + }) + .join('') + : responses.nameStatus + if (raw instanceof Error) { + return reply(raw, 'diff') + } + if (responses.numstat instanceof Error) { + return reply(responses.numstat, 'diff') + } + return reply((raw ?? '') + (responses.numstat ?? ''), 'diff') } if (args[0] === 'rev-list') { return reply(responses.revList, 'rev-list') @@ -245,7 +259,7 @@ describe('getBranchCompare', () => { expect(result.entries).toEqual([]) }) - it('passes core.quotePath=false to diff --name-status and parses UTF-8 paths', async () => { + it('loads UTF-8 paths and line counts with one diff', async () => { mockBranchCompareGit({ branch: 'main\n', probe: { 'refs/remotes/origin/main^{commit}': 'base-oid\n' }, @@ -260,16 +274,7 @@ describe('getBranchCompare', () => { const result = await getBranchCompare('/repo', 'origin/main') expect(gitExecFileAsyncMock).toHaveBeenCalledWith( - [ - '-c', - 'core.quotePath=false', - 'diff', - '--name-status', - '-M', - '-C', - 'merge-base-oid', - 'head-oid' - ], + ['diff', '--raw', '--numstat', '-z', '-M', '-C', 'merge-base-oid', 'head-oid', '--'], expect.objectContaining({ cwd: '/repo' }) ) expect(result.entries).toEqual([ @@ -361,10 +366,7 @@ describe('getBranchCompare', () => { if (args[0] === 'merge-base') { return Promise.resolve({ stdout: 'merge-base-oid\n' }) } - if (args.includes('--name-status')) { - return Promise.resolve({ stdout: '' }) - } - if (args.includes('--numstat')) { + if (args.includes('--raw')) { return Promise.resolve({ stdout: '' }) } if (args[0] === 'rev-list') { @@ -404,12 +406,9 @@ describe('getBranchCompare', () => { if (args[0] === 'merge-base') { return Promise.resolve({ stdout: 'merge-base-oid\n' }) } - if (args.includes('--name-status')) { - return Promise.resolve({ stdout: 'M\tdocs/a => b.txt\n' }) - } - if (args.includes('--numstat')) { + if (args.includes('--raw')) { return Promise.resolve({ - stdout: args.includes('-z') ? '1\t0\tdocs/a => b.txt\0' : '1\t0\tdocs/a => b.txt\n' + stdout: ':100644 100644 a b M\0docs/a => b.txt\0' + '1\t0\tdocs/a => b.txt\0' }) } if (args[0] === 'rev-list') { @@ -421,17 +420,7 @@ describe('getBranchCompare', () => { const result = await getBranchCompare('/repo', 'origin/main') expect(gitExecFileAsyncMock).toHaveBeenCalledWith( - [ - '-c', - 'core.quotePath=false', - 'diff', - '-z', - '--numstat', - '-M', - '-C', - 'merge-base-oid', - 'head-oid' - ], + ['diff', '--raw', '--numstat', '-z', '-M', '-C', 'merge-base-oid', 'head-oid', '--'], expect.objectContaining({ cwd: '/repo' }) ) expect(result.entries).toEqual([ @@ -454,12 +443,9 @@ describe('getCommitCompare', () => { if (args[0] === 'rev-list') { return Promise.resolve({ stdout: 'commit-oid parent-oid\n' }) } - if (args.includes('--name-status')) { - return Promise.resolve({ stdout: 'M\tdocs/a => b.txt\n' }) - } - if (args.includes('--numstat')) { + if (args.includes('--raw')) { return Promise.resolve({ - stdout: args.includes('-z') ? '1\t0\tdocs/a => b.txt\0' : '1\t0\tdocs/a => b.txt\n' + stdout: ':100644 100644 a b M\0docs/a => b.txt\0' + '1\t0\tdocs/a => b.txt\0' }) } throw new Error(`unexpected git args: ${args.join(' ')}`) @@ -468,17 +454,7 @@ describe('getCommitCompare', () => { const result = await getCommitCompare('/repo', 'commit-oid') expect(gitExecFileAsyncMock).toHaveBeenCalledWith( - [ - '-c', - 'core.quotePath=false', - 'diff', - '-z', - '--numstat', - '-M', - '-C', - 'parent-oid', - 'commit-oid' - ], + ['diff', '--raw', '--numstat', '-z', '-M', '-C', 'parent-oid', 'commit-oid', '--'], expect.objectContaining({ cwd: '/repo' }) ) expect(result.entries).toEqual([ diff --git a/src/main/git/status-diff-settled-cache.test.ts b/src/main/git/status-diff-settled-cache.test.ts index 40d455441bf..cd3c35832ab 100644 --- a/src/main/git/status-diff-settled-cache.test.ts +++ b/src/main/git/status-diff-settled-cache.test.ts @@ -358,9 +358,12 @@ describe('settled diff cache', () => { }) it('caches a new file whose absence from the index git actually reported', async () => { - gitExecFileAsyncBufferMock.mockRejectedValue( - Object.assign(new Error("fatal: path 'src/file.ts' does not exist"), { code: 128 }) - ) + gitExecFileAsyncBufferMock.mockImplementation(async (args: string[]) => { + const stderr = args.some((arg) => arg.startsWith(':')) + ? "fatal: path 'src/file.ts' exists on disk, but not in the index" + : "fatal: path 'src/file.ts' exists on disk, but not in 'HEAD'" + throw Object.assign(new Error(stderr), { code: 128, stderr: Buffer.from(stderr) }) + }) await getDiff(REPO, FILE, false) const spawnsAfterFirst = blobReadCount() diff --git a/src/main/git/status-diff.test.ts b/src/main/git/status-diff.test.ts index d66c64e7a89..9eb4aee0b21 100644 --- a/src/main/git/status-diff.test.ts +++ b/src/main/git/status-diff.test.ts @@ -130,7 +130,12 @@ describe('getDiff', () => { it('falls back to HEAD for unstaged diffs when the file is not in the index', async () => { gitExecFileAsyncBufferMock - .mockRejectedValueOnce(new Error('missing index')) + .mockRejectedValueOnce( + Object.assign(new Error("fatal: path 'src/file.ts' exists on disk, but not in the index"), { + code: 128, + stderr: "fatal: path 'src/file.ts' exists on disk, but not in the index" + }) + ) .mockResolvedValueOnce({ stdout: Buffer.from('head-content\n') }) readFileMock.mockResolvedValue(Buffer.from('working-tree-content')) 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 2c6ec2058c3..8d92ff550a2 100644 --- a/src/main/git/status-discard-and-bulk-staging.test.ts +++ b/src/main/git/status-discard-and-bulk-staging.test.ts @@ -72,7 +72,7 @@ describe('discardChanges', () => { realpathMock.mockImplementation(async (targetPath: string) => path.resolve(targetPath)) }) - it('restores tracked files from HEAD', async () => { + it('restores tracked files from the index', async () => { gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'src/file.ts\n' }) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) @@ -87,7 +87,7 @@ describe('discardChanges', () => { ) expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith( 2, - ['restore', '--worktree', '--source=HEAD', '--', ':(literal)src/file.ts'], + ['restore', '--worktree', '--', ':(literal)src/file.ts'], { cwd: '/repo' } @@ -135,46 +135,32 @@ describe('bulk git helpers', () => { realpathMock.mockImplementation(async (targetPath: string) => path.resolve(targetPath)) }) - it('chunks bulk stage requests to avoid oversized argv payloads', async () => { + it('stages more than 100 selected paths with one index write and bounded argv', async () => { gitExecFileAsyncMock.mockResolvedValue({ stdout: '' }) const filePaths = Array.from({ length: 201 }, (_, i) => `src/file-${i}.ts`) await bulkStageFiles('/repo', filePaths) - expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(3) - expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith( - 1, - ['add', '--', ...filePaths.slice(0, 100).map((filePath) => `:(literal)${filePath}`)], + expect(gitExecFileAsyncMock).toHaveBeenCalledExactlyOnceWith( + ['add', '--pathspec-from-file=-', '--pathspec-file-nul'], { - cwd: '/repo' - } - ) - expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith( - 3, - ['add', '--', ...filePaths.slice(200).map((filePath) => `:(literal)${filePath}`)], - { - cwd: '/repo' + cwd: '/repo', + stdin: filePaths.map((filePath) => `:(literal)${filePath}\0`).join('') } ) }) - it('chunks bulk unstage requests to avoid oversized argv payloads', async () => { + it('unstages more than 100 selected paths with one index write and bounded argv', async () => { gitExecFileAsyncMock.mockResolvedValue({ stdout: '' }) const filePaths = Array.from({ length: 101 }, (_, i) => `src/file-${i}.ts`) await bulkUnstageFiles('/repo', filePaths) - expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) - expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith( - 2, - [ - 'restore', - '--staged', - '--', - ...filePaths.slice(100).map((filePath) => `:(literal)${filePath}`) - ], + expect(gitExecFileAsyncMock).toHaveBeenCalledExactlyOnceWith( + ['reset', '--quiet', '--pathspec-from-file=-', '--pathspec-file-nul'], { - cwd: '/repo' + cwd: '/repo', + stdin: filePaths.map((filePath) => `:(literal)${filePath}\0`).join('') } ) }) @@ -207,9 +193,10 @@ describe('bulk git helpers', () => { // tracked descendant, which keeps directory pathspecs on the restore path. expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith( 2, - ['restore', '--worktree', '--source=HEAD', '--', ':(literal)src/file.ts', ':(literal)docs'], + ['restore', '--worktree', '--pathspec-from-file=-', '--pathspec-file-nul'], { - cwd: '/repo' + cwd: '/repo', + stdin: ':(literal)src/file.ts\0:(literal)docs\0' } ) expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith( @@ -232,18 +219,12 @@ describe('bulk git helpers', () => { expect(gitExecFileAsyncMock.mock.calls.map(([args]) => args)).toEqual([ ['ls-files', '-z', '--', ...filePaths.map((filePath) => `:(literal)${filePath}`)], - [ - 'restore', - '--worktree', - '--source=HEAD', - '--', - ':(literal)docs\\', - ':(literal)[ab].txt', - ':(literal)docs///', - ':(literal)docs\\' - ], + ['restore', '--worktree', '--pathspec-from-file=-', '--pathspec-file-nul'], ['clean', '-ffdx', '--', ':(literal)new', ':(literal)new', ':(literal)src/file'] ]) + expect(gitExecFileAsyncMock.mock.calls[1][1].stdin).toBe( + ':(literal)docs\\\0:(literal)[ab].txt\0:(literal)docs///\0:(literal)docs\\\0' + ) expect(rmMock).not.toHaveBeenCalled() }) @@ -259,9 +240,10 @@ describe('bulk git helpers', () => { expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith( 2, - ['restore', '--worktree', '--source=HEAD', '--', ':(literal)docs'], + ['restore', '--worktree', '--pathspec-from-file=-', '--pathspec-file-nul'], { - cwd: '/repo' + cwd: '/repo', + stdin: ':(literal)docs\0' } ) expect(rmMock).not.toHaveBeenCalled() @@ -275,4 +257,19 @@ describe('bulk git helpers', () => { expect(gitExecFileAsyncMock).not.toHaveBeenCalled() expect(rmMock).not.toHaveBeenCalled() }) + + it('does not spawn mutations for empty selections', async () => { + await bulkStageFiles('/repo', []) + await bulkUnstageFiles('/repo', []) + await bulkDiscardChanges('/repo', []) + + expect(gitExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('rejects embedded NUL before staging or unstaging another path', async () => { + await expect(bulkStageFiles('/repo', ['one\0two'])).rejects.toThrow('NUL') + await expect(bulkUnstageFiles('/repo', ['one\0two'])).rejects.toThrow('NUL') + + expect(gitExecFileAsyncMock).not.toHaveBeenCalled() + }) }) diff --git a/src/main/git/status-submodule.test.ts b/src/main/git/status-submodule.test.ts index 6528d373a78..266ac4a399d 100644 --- a/src/main/git/status-submodule.test.ts +++ b/src/main/git/status-submodule.test.ts @@ -319,8 +319,8 @@ describe('getSubmoduleStatus', () => { gitExecFileAsyncMock.mockReset() gitExecFileAsyncMock.mockImplementation((args: string[]) => { // Clean worktree: the inner status stream returns nothing. - if (args.includes('--name-status')) { - return Promise.resolve({ stdout: 'M\tlib/main.dart\n' }) + if (args.includes('--raw')) { + return Promise.resolve({ stdout: ':100644 100644 a b M\0lib/main.dart\0' }) } if (args[0] === 'ls-files') { return Promise.resolve({ stdout: `160000 ${OLD_OID} 0\tflutter_mine\n` }) @@ -346,8 +346,8 @@ describe('getSubmoduleStatus', () => { gitExecFileAsyncMock.mockReset() gitExecFileAsyncMock.mockImplementation((args: string[]) => { // Clean submodule worktree: the staged parent gitlink still has files to show. - if (args.includes('--name-status')) { - return Promise.resolve({ stdout: 'M\tlib/main.dart\n' }) + if (args.includes('--raw')) { + return Promise.resolve({ stdout: ':100644 100644 a b M\0lib/main.dart\0' }) } if (args[0] === 'ls-files') { return Promise.resolve({ stdout: `160000 ${NEW_OID} 0\tflutter_mine\n` }) @@ -374,8 +374,10 @@ describe('getSubmoduleStatus', () => { const NEW_OID = 'b'.repeat(40) gitExecFileAsyncMock.mockReset() gitExecFileAsyncMock.mockImplementation((args: string[]) => { - if (args.includes('--name-status')) { - return Promise.resolve({ stdout: 'M\tlib/a.dart\nM\tlib/b.dart\n' }) + if (args.includes('--raw')) { + return Promise.resolve({ + stdout: ':100644 100644 a b M\0lib/a.dart\0:100644 100644 a b M\0lib/b.dart\0' + }) } if (args[0] === 'ls-files') { return Promise.resolve({ stdout: `160000 ${NEW_OID} 0\tflutter_mine\n` }) diff --git a/src/main/git/status-wsl-pathspecs.test.ts b/src/main/git/status-wsl-pathspecs.test.ts index e802fb72b5b..c735bb42493 100644 --- a/src/main/git/status-wsl-pathspecs.test.ts +++ b/src/main/git/status-wsl-pathspecs.test.ts @@ -44,7 +44,11 @@ describe('WSL git pathspecs', () => { const pathspecs = gitExecFileAsyncMock.mock.calls.flatMap(([args]) => (args as string[]).filter((arg) => arg.startsWith(':(literal)')) ) - expect(pathspecs).toEqual(Array(8).fill(':(literal)tests/breakgit')) + expect(pathspecs).toEqual(Array(5).fill(':(literal)tests/breakgit')) + const inputs = gitExecFileAsyncMock.mock.calls + .map(([, options]) => options.stdin) + .filter((input) => input !== undefined) + expect(inputs).toEqual(Array(3).fill(':(literal)tests/breakgit\0')) }) it('uses POSIX separators when discarding untracked files inside WSL', async () => { diff --git a/src/main/git/status.test.ts b/src/main/git/status.test.ts index 7b73739cd3f..f0c841ea4c5 100644 --- a/src/main/git/status.test.ts +++ b/src/main/git/status.test.ts @@ -572,7 +572,16 @@ describe('getStatus', () => { const result = await getStatus('/repo') expect(gitExecFileAsyncMock).toHaveBeenCalledWith( - ['-c', 'core.quotePath=false', 'diff', '-z', '--numstat', '-M'], + [ + '-c', + 'core.quotePath=false', + '-c', + 'diff.autoRefreshIndex=false', + 'diff', + '-z', + '--numstat', + '-M' + ], expect.objectContaining({ cwd: '/repo', env: expect.objectContaining({ GIT_OPTIONAL_LOCKS: '0' }) diff --git a/src/main/git/upstream.test.ts b/src/main/git/upstream.test.ts index 1c4c45e0911..fab30418eca 100644 --- a/src/main/git/upstream.test.ts +++ b/src/main/git/upstream.test.ts @@ -222,9 +222,7 @@ describe('getUpstreamStatus', () => { gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: 'feature\n' }) .mockResolvedValueOnce({ stdout: '\n' }) - .mockRejectedValueOnce(new Error('missing branch remote')) - .mockRejectedValueOnce(new Error('missing branch merge')) - .mockRejectedValueOnce(new Error('missing branch base')) + .mockResolvedValueOnce({ stdout: '' }) .mockRejectedValueOnce(new Error('missing remote branch')) const result = await getUpstreamStatus('/repo') @@ -240,9 +238,7 @@ describe('getUpstreamStatus', () => { gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: 'feature\n' }) .mockRejectedValueOnce(new Error('fatal: no upstream configured')) - .mockRejectedValueOnce(new Error('missing branch remote')) - .mockRejectedValueOnce(new Error('missing branch merge')) - .mockRejectedValueOnce(new Error('missing branch base')) + .mockResolvedValueOnce({ stdout: '' }) .mockRejectedValueOnce(new Error('missing remote branch')) const result = await getUpstreamStatus('/repo') @@ -258,9 +254,7 @@ describe('getUpstreamStatus', () => { gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: 'feature\n' }) .mockRejectedValueOnce(missingTrackingRefError) - .mockRejectedValueOnce(new Error('missing branch remote')) - .mockRejectedValueOnce(new Error('missing branch merge')) - .mockRejectedValueOnce(new Error('missing branch base')) + .mockResolvedValueOnce({ stdout: '' }) .mockRejectedValueOnce(new Error('missing remote branch')) const result = await getUpstreamStatus('/repo') @@ -613,6 +607,8 @@ describe('getUpstreamStatus', () => { [ [ 'log', + '--no-show-signature', + '--no-color', '--oneline', '--cherry-mark', '--right-only', diff --git a/src/main/git/upstream.ts b/src/main/git/upstream.ts index 18b2756a133..6c3a51ba150 100644 --- a/src/main/git/upstream.ts +++ b/src/main/git/upstream.ts @@ -36,7 +36,16 @@ async function getBehindCommitsArePatchEquivalent( ): Promise { try { const { stdout } = await gitExecFileAsync( - ['log', '--oneline', '--cherry-mark', '--right-only', `HEAD...${upstreamName}`, '--'], + [ + 'log', + '--no-show-signature', + '--no-color', + '--oneline', + '--cherry-mark', + '--right-only', + `HEAD...${upstreamName}`, + '--' + ], gitExecOptions(worktreePath, options) ) return upstreamOnlyCommitsArePatchEquivalent(stdout) diff --git a/src/main/git/worktree-branch-removal-host.test.ts b/src/main/git/worktree-branch-removal-host.test.ts new file mode 100644 index 00000000000..71119029825 --- /dev/null +++ b/src/main/git/worktree-branch-removal-host.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it, vi } from 'vitest' + +const { detachedBranchUse } = vi.hoisted(() => ({ detachedBranchUse: vi.fn(async () => false) })) +vi.mock('../../shared/git-worktree-admin', () => ({ + isBranchInDetachedWorktree: detachedBranchUse +})) +vi.mock('./local-repo-ref-maintenance', () => ({ + withRepoRefMaintenancePaused: (_reason: string, run: () => unknown) => run() +})) + +import { forceDeleteLocalBranch } from './worktree-branch-removal' + +describe('preserved branch cleanup execution-host options', () => { + it('uses the same WSL distro for both detached admin guards and the ref mutation', async () => { + const repoPath = String.raw`\\wsl.localhost\Ubuntu\home\user\repo` + const options = { wslDistro: 'Ubuntu' } + const runGit = vi.fn(async (argv: string[]) => ({ + stdout: + argv[0] === 'worktree' + ? 'worktree /home/user/repo\nHEAD abc\nbranch refs/heads/main\n\nworktree /home/user/detached\nHEAD abc\ndetached\n' + : '', + stderr: '' + })) + await forceDeleteLocalBranch(repoPath, 'feature', 'abc', runGit, options) + expect(detachedBranchUse).toHaveBeenCalledTimes(2) + for (const call of detachedBranchUse.mock.calls) { + expect(call).toEqual([repoPath, 'feature', expect.any(Array), options]) + } + expect(runGit).toHaveBeenCalledWith(['update-ref', '-d', 'refs/heads/feature', 'abc'], repoPath) + }) +}) diff --git a/src/main/git/worktree-branch-removal.ts b/src/main/git/worktree-branch-removal.ts index dc09311596c..22a33f9da25 100644 --- a/src/main/git/worktree-branch-removal.ts +++ b/src/main/git/worktree-branch-removal.ts @@ -6,6 +6,7 @@ import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' import { withLocalGitCapabilityCacheForExecution } from './git-capability-state' import { withRepoRefMaintenancePaused } from './local-repo-ref-maintenance' import { gitExecFileAsync } from './runner' +import { isBranchInDetachedWorktree } from '../../shared/git-worktree-admin' import { parseWorktreeList } from '../../shared/git-worktree-porcelain-parser' import { isBranchCheckedOutInWorktreeError } from '../../shared/git-branch-delete-refusal' import type { GitWorktreeExecOptions, RemoveWorktreeOptions } from './worktree-operation-options' @@ -122,8 +123,12 @@ async function deleteAlreadyMergedBranchAfterSafeDeleteFailure( if (!hasNoUnmergedChanges) { return false } - await forceDeleteLocalBranch(repoPath, branchName, branchHead, (args, cwd) => - gitExecFileAsync(args, gitExecOptions(cwd, options)) + await forceDeleteLocalBranch( + repoPath, + branchName, + branchHead, + (args, cwd) => gitExecFileAsync(args, gitExecOptions(cwd, options)), + options ) return true } @@ -135,7 +140,8 @@ export async function forceDeleteLocalBranch( runGit: (args: string[], cwd: string) => Promise<{ stdout: string; stderr: string }> = ( args, cwd - ) => gitExecFileAsync(args, { cwd }) + ) => gitExecFileAsync(args, gitExecOptions(cwd, options)), + options: GitWorktreeExecOptions = {} ): Promise { if (!branchName || branchName.includes('\0')) { throw new Error('Invalid branch name') @@ -145,7 +151,7 @@ export async function forceDeleteLocalBranch( `Cannot force-delete local branch "${branchName}" without the commit Git preserved.` ) } - if (await isLocalBranchCheckedOut(repoPath, branchName, runGit)) { + if (await isLocalBranchCheckedOut(repoPath, branchName, runGit, options)) { 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. @@ -160,7 +166,11 @@ export async function forceDeleteLocalBranch( `Local branch "${branchName}" changed after the workspace was deleted. Review it before deleting it.` ) } - if (await isLocalBranchCheckedOut(repoPath, branchName, runGit)) { + try { + if (await isLocalBranchCheckedOut(repoPath, branchName, runGit, options)) { + throw new Error(`Local branch "${branchName}" is checked out in another worktree.`) + } + } catch (error) { try { await runGit(['update-ref', `refs/heads/${branchName}`, expectedHead, ''], repoPath) } catch (restoreError) { @@ -169,7 +179,7 @@ export async function forceDeleteLocalBranch( restoreError ) } - throw new Error(`Local branch "${branchName}" is checked out in another worktree.`) + throw error } try { await runGit(['config', '--remove-section', `branch.${branchName}`], repoPath) @@ -181,10 +191,13 @@ export async function forceDeleteLocalBranch( async function isLocalBranchCheckedOut( repoPath: string, branchName: string, - runGit: (args: string[], cwd: string) => Promise<{ stdout: string; stderr: string }> + runGit: (args: string[], cwd: string) => Promise<{ stdout: string; stderr: string }>, + options: GitWorktreeExecOptions ): Promise { const { stdout } = await runGit(['worktree', 'list', '--porcelain'], repoPath) - return parseWorktreeList(stdout).some( - (worktree) => normalizeLocalBranchRef(worktree.branch) === branchName + const worktrees = parseWorktreeList(stdout) + return ( + worktrees.some((worktree) => normalizeLocalBranchRef(worktree.branch) === branchName) || + isBranchInDetachedWorktree(repoPath, branchName, worktrees, options) ) } diff --git a/src/main/git/worktree-create-git-executor-real-git.test.ts b/src/main/git/worktree-create-git-executor-real-git.test.ts index 02989fc995e..fd26776790d 100644 --- a/src/main/git/worktree-create-git-executor-real-git.test.ts +++ b/src/main/git/worktree-create-git-executor-real-git.test.ts @@ -88,7 +88,9 @@ it('creates cold and prepared worktrees with real Git while status capacity is o ) expect(await listWorktrees(repo)).toHaveLength(3) }) - expect(events.some((event) => event.args.includes('core.sshCommand'))).toBe(true) + expect( + events.filter((event) => event.args[0] === 'config').map((event) => event.args) + ).toContainEqual(['config', '--null', '--get-regexp', '^(core\\.sshcommand|ssh\\.variant)$']) expect(events.some((event) => event.args.includes('fetch'))).toBe(true) expect(events.every((event) => event.tier === 'interactive')).toBe(true) expect( diff --git a/src/main/git/worktree-create-preparation-real-git.test.ts b/src/main/git/worktree-create-preparation-real-git.test.ts index 7233cc681fc..c736a1774f0 100644 --- a/src/main/git/worktree-create-preparation-real-git.test.ts +++ b/src/main/git/worktree-create-preparation-real-git.test.ts @@ -133,6 +133,7 @@ describe('prepared worktree creation with real Git', () => { const barrier = new Promise((resolve) => { release = resolve }) + const barrierSubscribed = vi.spyOn(barrier, 'then') const spy = vi.spyOn(gitRunner, 'gitExecFileAsync') const preparing = prepareWorktreeCreateCheckout( repoPath, @@ -147,6 +148,11 @@ describe('prepared worktree creation with real Git', () => { await vi.waitFor(() => expect(existsSync(join(preparedPath, '.git'))).toBe(true)) const lock = git(preparedPath, ['rev-parse', '--git-path', 'locked']) await vi.waitFor(async () => expect(await readFile(lock, 'utf8')).toBe(`${reason}\n`)) + await vi.waitFor(() => + expect( + barrierSubscribed.mock.calls.some(([onFulfilled]) => typeof onFulfilled === 'function') + ).toBe(true) + ) controller.abort(new Error('expired while fetching')) await assertion expect(existsSync(preparedPath)).toBe(false) @@ -156,6 +162,7 @@ describe('prepared worktree creation with real Git', () => { expect(spy.mock.calls.some(([args]) => args.includes('reset'))).toBe(false) } finally { release() + barrierSubscribed.mockRestore() spy.mockRestore() } }) diff --git a/src/main/git/worktree-graph-listing.test.ts b/src/main/git/worktree-graph-listing.test.ts index c569ce9e7b8..8a54b2beb5b 100644 --- a/src/main/git/worktree-graph-listing.test.ts +++ b/src/main/git/worktree-graph-listing.test.ts @@ -9,6 +9,10 @@ const { gitExecFileAsyncMock, gitExecFileSyncMock, translateWslOutputPathsMock } }) ) +vi.mock('../../shared/git-worktree-admin', () => ({ + annotateWorktreeLocksFromAdmin: async (_repoPath: string, rows: unknown[]) => rows +})) + vi.mock('./runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock, gitExecFileSync: gitExecFileSyncMock, diff --git a/src/main/git/worktree-list-porcelain.test.ts b/src/main/git/worktree-list-porcelain.test.ts index eed9891ac22..fb95bdcd7dd 100644 --- a/src/main/git/worktree-list-porcelain.test.ts +++ b/src/main/git/worktree-list-porcelain.test.ts @@ -17,6 +17,10 @@ const { resolveGitDirMock: vi.fn() })) +vi.mock('../../shared/git-worktree-admin', () => ({ + annotateWorktreeLocksFromAdmin: async (_repoPath: string, rows: unknown[]) => rows +})) + vi.mock('./runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock, gitExecFileSync: gitExecFileSyncMock, diff --git a/src/main/git/worktree-list-reader.ts b/src/main/git/worktree-list-reader.ts index 1ba9f0a85de..ba75f31d837 100644 --- a/src/main/git/worktree-list-reader.ts +++ b/src/main/git/worktree-list-reader.ts @@ -1,3 +1,4 @@ +import { annotateWorktreeLocksFromAdmin } from '../../shared/git-worktree-admin' import { stat } from 'node:fs/promises' import type { GitWorktreeInfo } from '../../shared/worktree/types' import { toWslExecutionSpace } from '../../shared/wsl-paths' @@ -241,7 +242,11 @@ export async function readWorktreeList( ) // 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) + return annotatePrunableByExistence( + await annotateWorktreeLocksFromAdmin(repoPath, normalized, options), + repoPath, + options + ) }, isUnsupportedWorktreeListZError ) @@ -261,8 +266,7 @@ async function annotatePrunableByExistence( 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. + // Git only prunes linked worktrees, never locked ones (a lock shields a missing directory). A missing main worktree is handled by the repo-level ENOENT paths. if ( !worktree || worktree.isMainWorktree || diff --git a/src/main/git/worktree-removal.ts b/src/main/git/worktree-removal.ts index 8924fe04a9f..2f7eb8c38e0 100644 --- a/src/main/git/worktree-removal.ts +++ b/src/main/git/worktree-removal.ts @@ -2,7 +2,6 @@ import { lstat } from 'node:fs/promises' import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' import { assertWorktreeUnlockedForRemoval } from '../../shared/worktree/removal' import { windowsLongPathGitArgs } from '../../shared/windows-long-path-git-args' -import { isSubmoduleWorktreeRemovalRefusal } from '../../shared/worktree/submodule-removal' import { removeHostTree } from '../host-tree-removal' import { withSpan } from '../observability/tracer' import { parseWslPath } from '../wsl' @@ -13,7 +12,6 @@ import { invalidateWslLinkedWorktreeGitRouting } from './wsl-linked-worktree-git import type { RemoveWorktreeOptions } from './worktree-operation-options' import { getErrorCode, gitExecOptions, normalizeLocalBranchRef } from './worktree-operation-options' import { areWorktreePathsEqual } from './worktree-path-comparison' -import { assertWorktreeCleanForRemoval } from './worktree-removal-preflight' import { withRepoRefMaintenancePaused } from './local-repo-ref-maintenance' import { bumpWorktreeScanGeneration, listWorktrees } from './worktree-scan-cache' import { invalidateSparseCheckoutState } from './worktree-sparse-checkout-cache' @@ -82,19 +80,7 @@ async function performRemoveWorktree( } args.push(worktreePath) await runUnderWorktreeDeleteLimit(async () => { - try { - await gitExecFileAsync(args, execOptions) - } 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( - [...longPathArgs, 'worktree', 'remove', '--force', worktreePath], - execOptions - ) - } + await gitExecFileAsync(args, execOptions) await removeCheckoutLeftByGit(worktreePath, options) }) diff --git a/src/main/git/worktree-safety-real-git.test.ts b/src/main/git/worktree-safety-real-git.test.ts new file mode 100644 index 00000000000..7a31e2dfc5f --- /dev/null +++ b/src/main/git/worktree-safety-real-git.test.ts @@ -0,0 +1,284 @@ +import { execFile } from 'node:child_process' +import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { promisify } from 'node:util' +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { GitCapabilityCache } from '../../shared/git-capability-cache' +import { isWorktreeCreatePreparation } from '../../shared/worktree/create-preparation' +import { parseWorktreeList } from '../../shared/git-worktree-porcelain-parser' +import { annotateWorktreeLocksFromAdmin } from '../../shared/git-worktree-admin' +import type { GitExec } from '../../relay/git-handler-ops' + +const { runner } = vi.hoisted(() => ({ runner: vi.fn() })) +vi.mock('./runner', () => ({ + gitExecFileAsync: runner, + gitExecFileSync: vi.fn(), + translateWslOutputPaths: (value: string) => value +})) +vi.mock('./status', () => ({ runWithGitReadCacheInvalidation: (run: () => unknown) => run() })) + +import { removeWorktree } from './worktree-removal' +import { forceDeleteLocalBranch } from './worktree-branch-removal' +import { readWorktreeList } from './worktree-list-reader' +import { getBranchConflictKind, getBranchConflictKindViaExec } from './repo-branch-conflict' +import { clearGitCapabilityStateForTests } from './git-capability-state' +import { removeWorktreeOp } from '../../relay/git-handler-worktree-remove' +import { forceDeletePreservedRelayBranch } from '../../relay/git-handler-branch-cleanup' +import { readRelayWorktreeList } from '../../relay/git-handler-worktree-list' + +const execFileAsync = promisify(execFile) +const image = process.env.ORCA_GIT_COMPAT_IMAGE +const binary = process.env.ORCA_GIT_COMPAT_BINARY ?? 'git' +const expectedVersion = process.env.ORCA_GIT_COMPAT_VERSION +const dockerUser = + typeof process.getuid === 'function' && typeof process.getgid === 'function' + ? ['--user', `${process.getuid()}:${process.getgid()}`] + : [] +let root = '' +let repo = '' +let checkout = '' + +async function git(args: string[], cwd = repo): Promise<{ stdout: string; stderr: string }> { + return image + ? execFileAsync( + 'docker', + [ + 'run', + '--rm', + '--network=none', + ...dockerUser, + '-v', + `${root}:${root}`, + '-w', + cwd, + image, + '-c', + `safe.directory=${cwd}`, + ...args + ], + { maxBuffer: 2 * 1024 * 1024 } + ) + : execFileAsync(binary, args, { + cwd, + env: { ...process.env, HOME: root, XDG_CONFIG_HOME: root, GIT_CONFIG_NOSYSTEM: '1' }, + maxBuffer: 2 * 1024 * 1024 + }) +} + +const relay: GitExec = (args, cwd) => git(args, cwd) + +async function initializeRepo(cwd: string): Promise { + await mkdir(cwd, { recursive: true }) + await git(['init', '-q', '-b', 'main'], cwd).catch(() => git(['init', '-q'], cwd)) + await git(['config', 'user.name', 'Worktree Safety'], cwd) + await git(['config', 'user.email', 'safety@example.invalid'], cwd) + await git(['config', 'commit.gpgSign', 'false'], cwd) + await git(['config', 'core.hooksPath', '.git/no-hooks'], cwd) + await git(['config', 'gc.auto', '0'], cwd) + await writeFile(join(cwd, 'seed'), 'seed\n') + await git(['add', 'seed'], cwd) + await git(['commit', '-qm', 'seed'], cwd) +} + +beforeAll(async () => { + const { stdout } = await execFileAsync( + image ? 'docker' : binary, + image ? ['run', '--rm', '--network=none', ...dockerUser, image, '--version'] : ['--version'] + ) + expect(stdout).toContain(expectedVersion ? `git version ${expectedVersion}` : 'git version ') +}) + +beforeEach(async () => { + root = await realpath(await mkdtemp(join(tmpdir(), 'orca-worktree-safety-'))) + repo = join(root, 'repo') + checkout = join(root, 'checkout') + await initializeRepo(repo) + clearGitCapabilityStateForTests() + runner.mockImplementation((args: string[], options: { cwd?: string }) => git(args, options.cwd)) +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +describe('worktree safety with the real Git binary', () => { + it.each(['native', 'relay'] as const)( + '%s preserves unpublished submodule objects without implicit force', + async (host) => { + const origin = join(root, 'sub-origin') + await initializeRepo(origin) + await git(['-c', 'protocol.file.allow=always', 'submodule', 'add', origin, 'sub']) + await git(['commit', '-qam', 'submodule']) + await git(['worktree', 'add', '-q', '-b', 'feature', checkout]) + await git(['-c', 'protocol.file.allow=always', 'submodule', 'update', '--init'], checkout) + const sub = join(checkout, 'sub') + await git(['checkout', '-qb', 'unpublished'], sub) + await writeFile(join(sub, 'local'), 'unpublished work\n') + await git(['add', 'local'], sub) + await git( + [ + '-c', + 'user.name=Safety', + '-c', + 'user.email=safety@example.invalid', + '-c', + 'commit.gpgSign=false', + 'commit', + '-qm', + 'local' + ], + sub + ) + const local = (await git(['rev-parse', 'HEAD'], sub)).stdout.trim() + await git(['checkout', '--detach', 'HEAD~1'], sub) + expect( + (await git(['status', '--porcelain', '--ignore-submodules=none'], checkout)).stdout + ).toBe('') + + const remove = (force: boolean) => + host === 'native' + ? removeWorktree(repo, checkout, force, { + deleteBranch: false, + knownRemovedWorktree: { branch: 'refs/heads/feature', head: '', locked: false } + }) + : removeWorktreeOp( + relay, + { worktreePath: checkout, force, deleteBranch: false }, + new GitCapabilityCache() + ) + await expect(remove(false)).rejects.toThrow(/submodules cannot be moved or removed/) + expect((await git(['cat-file', '-t', local], sub)).stdout.trim()).toBe('commit') + expect((await git(['worktree', 'list', '--porcelain'])).stdout).toContain(checkout) + await expect(remove(true)).resolves.toEqual({}) + await expect(readFile(join(checkout, '.git'))).rejects.toThrow() + }, + 120_000 + ) + + it.each(['native', 'relay'] as const)( + '%s reads preparation ownership from admin metadata on old porcelain', + async (host) => { + await git(['worktree', 'add', '-q', '--detach', checkout]) + const reason = 'orca-create-preparation:v1:12345:retained' + await git(['worktree', 'lock', '--reason', reason, checkout]) + const oldList = (await git(['worktree', 'list', '--porcelain'])).stdout + .split('\n') + .filter((line) => !line.startsWith('locked')) + .join('\n') + const capabilities = new GitCapabilityCache() + capabilities.rememberUnsupported('worktree-list-z') + runner.mockImplementation(async (args: string[], options: { cwd?: string }) => { + if (args[0] === 'worktree' && args[1] === 'list') { + if (args.includes('-z')) { + throw Object.assign(new Error("unknown switch `z'"), { + code: 129, + stderr: "error: unknown switch `z'" + }) + } + return { stdout: oldList, stderr: '' } + } + return git(args, options.cwd) + }) + const entries = + host === 'native' + ? await readWorktreeList(repo) + : await readRelayWorktreeList((args, cwd) => runner(args, { cwd }), repo, capabilities) + const prepared = entries.find((entry) => entry.path === checkout) + expect(prepared).toMatchObject({ locked: true, lockReason: reason }) + expect(prepared && isWorktreeCreatePreparation(prepared)).toBe(true) + await rm(checkout, { recursive: true }) + const annotated = await annotateWorktreeLocksFromAdmin(repo, parseWorktreeList(oldList)) + expect(annotated.find((entry) => entry.path === checkout)).toMatchObject({ + locked: true, + lockReason: reason + }) + }, + 90_000 + ) + + it.each([ + ['feature', 'Feature'], + ['Ä', 'ä'], + ['K', 'K'], + ['ß', 'ẞ'] + ])( + 'protects packed %s from the alias %s on native and SSH hosts', + async (existing, candidate) => { + await git(['branch', existing]) + await git(['pack-refs', '--all']) + const before = (await git(['rev-parse', `refs/heads/${existing}`])).stdout + await writeFile(join(repo, 'seed'), 'advanced base\n') + await git(['commit', '-qam', 'advance']) + for (const setting of ['true', 'false', 'unset']) { + await git( + setting === 'unset' + ? ['config', '--unset', 'core.ignoreCase'] + : ['config', 'core.ignoreCase', setting] + ) + await expect(getBranchConflictKind(repo, candidate)).resolves.toBe('local') + await expect(getBranchConflictKindViaExec((args) => git(args), candidate)).resolves.toBe( + 'local' + ) + expect((await git(['rev-parse', `refs/heads/${existing}`])).stdout).toBe(before) + } + }, + 90_000 + ) + + it.each(['native', 'relay'] as const)( + '%s retains detached branches reserved by rebase or bisect', + async (host) => { + await git(['worktree', 'add', '-q', '-b', 'feature', checkout]) + const expected = (await git(['rev-parse', 'refs/heads/feature'])).stdout.trim() + await git(['checkout', '--detach'], checkout) + const gitDir = (await git(['rev-parse', '--absolute-git-dir'], checkout)).stdout.trim() + const remove = () => + host === 'native' + ? forceDeleteLocalBranch(repo, 'feature', expected, git) + : forceDeletePreservedRelayBranch(relay, repo, 'feature', expected) + for (const marker of ['rebase-merge/head-name', 'rebase-apply/head-name', 'BISECT_START']) { + const file = join(gitDir, ...marker.split('/')) + await mkdir(join(file, '..'), { recursive: true }) + await writeFile(file, marker === 'BISECT_START' ? 'feature\n' : 'refs/heads/feature\n') + if (marker === 'BISECT_START') { + await writeFile(join(gitDir, 'BISECT_LOG'), '') + } + await expect(git(['branch', '-D', 'feature'])).rejects.toThrow( + /checked out|used by worktree/ + ) + await expect(remove()).rejects.toThrow('checked out in another worktree') + expect((await git(['rev-parse', 'refs/heads/feature'])).stdout.trim()).toBe(expected) + await rm(file) + if (marker === 'BISECT_START') { + await rm(join(gitDir, 'BISECT_LOG')) + } + } + await expect(remove()).resolves.toBeUndefined() + }, + 120_000 + ) + it.each(['native', 'relay'] as const)( + '%s restores a deleted ref when detached branch use starts during deletion', + async (host) => { + await git(['worktree', 'add', '-q', '-b', 'feature', checkout]) + const expected = (await git(['rev-parse', 'refs/heads/feature'])).stdout.trim() + await git(['checkout', '--detach'], checkout) + const gitDir = (await git(['rev-parse', '--absolute-git-dir'], checkout)).stdout.trim() + const racingGit = async (args: string[], cwd: string) => { + const result = await git(args, cwd) + if (args[0] === 'update-ref' && args[1] === '-d') { + await writeFile(join(gitDir, 'BISECT_START'), 'feature\n') + } + return result + } + const remove = () => + host === 'native' + ? forceDeleteLocalBranch(repo, 'feature', expected, racingGit) + : forceDeletePreservedRelayBranch(racingGit, repo, 'feature', expected) + await expect(remove()).rejects.toThrow('checked out in another worktree') + expect((await git(['rev-parse', 'refs/heads/feature'])).stdout.trim()).toBe(expected) + }, + 90_000 + ) +}) diff --git a/src/main/git/wsl-direct-git-read-commands.ts b/src/main/git/wsl-direct-git-read-commands.ts index 1072ceb2107..e822d1d7aa1 100644 --- a/src/main/git/wsl-direct-git-read-commands.ts +++ b/src/main/git/wsl-direct-git-read-commands.ts @@ -1,180 +1,6 @@ -/** - * Decide whether a git invocation is a plain read that can run without a shell. - * - * Why: WSL-routed git otherwise goes through the distro user's interactive login - * shell, purely to inherit their PATH. That shell also runs the distro's rc/motd - * and writes it to the stdout callers parse. Reads need none of it -- the direct - * route supplies PATH and HOME explicitly and starts no shell at all. - * - * Writes and network operations stay on the login shell: they can depend on - * credential helpers, ssh-agent and other environment only the user's profile - * sets up. - */ - -// Subcommands that only ever read. `status` is here for completeness; its -// callers already opted in explicitly. -const ALWAYS_READ_SUBCOMMANDS = new Set([ - 'blame', - 'cat-file', - 'check-ref-format', - 'check-ignore', - 'describe', - 'diff', - 'for-each-ref', - 'log', - 'ls-files', - 'ls-tree', - 'merge-base', - 'name-rev', - 'rev-list', - 'rev-parse', - 'show', - 'show-ref', - 'status', - 'var' -]) - -// Read markers that appear as a flag anywhere after the subcommand. -const READ_FLAG_SUBCOMMANDS: Record> = { - branch: new Set([ - '--list', - '-l', - '--show-current', - '--contains', - '--points-at', - '--all', - '-a', - '--remotes', - '-r' - ]), - config: new Set(['--get', '--get-all', '--get-regexp', '--get-urlmatch', '--list', '-l']) -} - -const BRANCH_MUTATION_FLAGS = new Set([ - '--copy', - '--create-reflog', - '--delete', - '--edit-description', - '--force', - '--move', - '--no-create-reflog', - '--no-track', - '--recurse-submodules', - '--set-upstream-to', - '--track', - '--unset-upstream' -]) -const BRANCH_MUTATION_SHORT_FLAGS = new Set(['c', 'C', 'd', 'D', 'f', 'm', 'M', 't', 'u']) - -function hasBranchMutationFlag(args: readonly string[]): boolean { - return args.some((arg) => { - const flag = arg.split('=')[0] - if (BRANCH_MUTATION_FLAGS.has(flag)) { - return true - } - return ( - /^-[^-]/.test(flag) && - flag - .slice(1) - .split('') - .some((part) => BRANCH_MUTATION_SHORT_FLAGS.has(part)) - ) - }) -} - -// Read markers that must be the *first non-flag* argument, i.e. the action. -// Position matters here: matching them anywhere would read `worktree remove list` -// as a listing, because a worktree may legitimately be named "list". -const READ_ACTION_SUBCOMMANDS: Record> = { - remote: new Set(['get-url']), - submodule: new Set(['status']), - worktree: new Set(['list']) -} - -// Subcommands whose action-less form only lists (`git remote`, `git submodule`). -const BARE_FORM_IS_READ = new Set(['remote', 'submodule']) - -/** Leading `-c key=value` / `--git-dir=...` style options precede the subcommand. */ -export function findGitSubcommandIndex(args: readonly string[]): number { - for (let index = 0; index < args.length; index += 1) { - const arg = args[index] - if (arg === '-c' || arg === '-C') { - index += 1 - continue - } - if (arg.startsWith('-')) { - continue - } - return index - } - return -1 -} - -export function isWslDirectGitReadCommand(args: readonly string[]): boolean { - const subcommandIndex = findGitSubcommandIndex(args) - if (subcommandIndex === -1) { - return false - } - const subcommand = args[subcommandIndex] - if (ALWAYS_READ_SUBCOMMANDS.has(subcommand)) { - return true - } - const rest = args.slice(subcommandIndex + 1) - - if (subcommand === 'symbolic-ref') { - if (rest.some((arg) => arg === '-d' || arg === '--delete' || arg === '-m')) { - return false - } - // Reading takes one ref; a second positional is the value being written. - return rest.filter((arg) => arg !== '--' && !arg.startsWith('-')).length <= 1 - } - - if (subcommand === 'branch' && hasBranchMutationFlag(rest)) { - return false - } - - const readActions = READ_ACTION_SUBCOMMANDS[subcommand] - if (readActions) { - const action = rest.find((arg) => !arg.startsWith('-')) - if (!action) { - return BARE_FORM_IS_READ.has(subcommand) - } - // `remote show` queries the transport unless -n is given, so the queried - // form has to keep the profile's SSH and credential setup. - if (subcommand === 'remote' && action === 'show') { - return rest.includes('-n') - } - return readActions.has(action) - } - - const readFlags = READ_FLAG_SUBCOMMANDS[subcommand] - return Boolean(readFlags && rest.some((arg) => readFlags.has(arg.split('=')[0]))) -} - -export type GitCommandClass = 'network' | 'read' | 'other' - -const NETWORK_SUBCOMMANDS = new Set(['fetch', 'pull', 'push', 'clone', 'ls-remote']) - -function positionalAction(args: readonly string[], subcommandIndex: number): string | undefined { - return args.slice(subcommandIndex + 1).find((arg) => !arg.startsWith('-')) -} - -/** Classify only commands whose dominant phase is remote transfer as network work. */ -export function classifyGitCommand(args: readonly string[]): GitCommandClass { - const subcommandIndex = findGitSubcommandIndex(args) - if (subcommandIndex === -1) { - return 'other' - } - const subcommand = args[subcommandIndex] - if (NETWORK_SUBCOMMANDS.has(subcommand)) { - return 'network' - } - const action = positionalAction(args, subcommandIndex) - if ( - (subcommand === 'submodule' && action === 'update') || - (subcommand === 'remote' && action === 'update') - ) { - return 'network' - } - return isWslDirectGitReadCommand(args) ? 'read' : 'other' -} +export { + classifyGitCommand, + findGitSubcommandIndex, + isWslDirectGitReadCommand +} from '../../shared/git-command-classification' +export type { GitCommandClass } from '../../shared/git-command-classification' diff --git a/src/main/gitea/client.test.ts b/src/main/gitea/client.test.ts index eada3d6cd95..ab581ab3942 100644 --- a/src/main/gitea/client.test.ts +++ b/src/main/gitea/client.test.ts @@ -31,11 +31,8 @@ function primeGitExecWithDefaultBranch(defaultRef = 'refs/remotes/origin/main'): if (args[0] === 'remote') { return { stdout: 'https://git.example.com/team/repo.git\n', stderr: '' } } - if (args[0] === 'symbolic-ref' && args.includes('refs/remotes/origin/HEAD')) { - return { stdout: `${defaultRef}\n`, stderr: '' } - } - if (args[0] === 'rev-parse' && args[1] === '--verify' && args.includes(defaultRef)) { - return { stdout: 'default-oid\n', stderr: '' } + if (args[0] === 'for-each-ref' && args.includes('--format=%(refname)%00%(symref)')) { + return { stdout: `refs/remotes/origin/HEAD\0${defaultRef}\n`, stderr: '' } } throw new Error(`unexpected git call: ${args.join(' ')}`) }) diff --git a/src/main/github/default-branch-stale-pr.test.ts b/src/main/github/default-branch-stale-pr.test.ts index 276d63e38ac..8facc4867f0 100644 --- a/src/main/github/default-branch-stale-pr.test.ts +++ b/src/main/github/default-branch-stale-pr.test.ts @@ -136,23 +136,15 @@ import { __resetRepoDefaultBranchCacheForTests } from '../source-control/repo-de const DEFAULT_BRANCH_REF = 'refs/remotes/origin/master' /** - * Answer the real resolveDefaultBaseRefViaExec probes (origin/HEAD - * symbolic-ref + rev-parse verification), the merged-at-head `rev-parse HEAD` - * probe, and the tracked-upstream `for-each-ref` snapshot. + * Answer the default-base and tracked-upstream snapshots and merged-at-head probe. */ function primeGitExecForDefaultBranch({ defaultRef = DEFAULT_BRANCH_REF, headOid = 'checkout-head-oid' }: { defaultRef?: string; headOid?: string } = {}): void { gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { - if (args[0] === 'symbolic-ref' && args.includes('refs/remotes/origin/HEAD')) { - return { stdout: `${defaultRef}\n`, stderr: '' } - } - if (args[0] === 'rev-parse' && args[1] === '--verify') { - if (args.includes(defaultRef)) { - return { stdout: 'default-branch-oid\n', stderr: '' } - } - throw new Error(`fatal: Needed a single revision: ${args.join(' ')}`) + if (args[0] === 'for-each-ref' && args.includes('--format=%(refname)%00%(symref)')) { + return { stdout: `refs/remotes/origin/HEAD\0${defaultRef}\n`, stderr: '' } } if (args[0] === 'rev-parse' && args[1] === 'HEAD') { return { stdout: `${headOid}\n`, stderr: '' } diff --git a/src/main/gitlab/client-mr-test-harness.ts b/src/main/gitlab/client-mr-test-harness.ts index 53efedb7992..bcf40affb09 100644 --- a/src/main/gitlab/client-mr-test-harness.ts +++ b/src/main/gitlab/client-mr-test-harness.ts @@ -21,11 +21,8 @@ export function primeGitDefaultBranch( defaultRef = 'refs/remotes/origin/main' ): void { gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { - if (args[0] === 'symbolic-ref' && args.includes('refs/remotes/origin/HEAD')) { - return { stdout: `${defaultRef}\n`, stderr: '' } - } - if (args[0] === 'rev-parse' && args[1] === '--verify' && args.includes(defaultRef)) { - return { stdout: 'default-oid\n', stderr: '' } + if (args[0] === 'for-each-ref' && args.includes('--format=%(refname)%00%(symref)')) { + return { stdout: `refs/remotes/origin/HEAD\0${defaultRef}\n`, stderr: '' } } throw new Error(`unexpected git call: ${args.join(' ')}`) }) diff --git a/src/main/ipc/repos-add-linked-worktree.test.ts b/src/main/ipc/repos-add-linked-worktree.test.ts index 97cef8da5e0..6feb56dba56 100644 --- a/src/main/ipc/repos-add-linked-worktree.test.ts +++ b/src/main/ipc/repos-add-linked-worktree.test.ts @@ -45,6 +45,11 @@ vi.mock('electron', () => ({ vi.mock('../git/repo', () => ({ isGitRepo: isGitRepoMock, + inspectGitRepoForRegistration: vi.fn((path: string) => ({ + isRepo: isGitRepoMock(path), + rootPath: getGitRepoRootMock(path), + mainRepoPath: getLinkedWorktreeMainRepoRootMock(path) + })), getGitRepoRoot: getGitRepoRootMock, getLinkedWorktreeMainRepoRoot: getLinkedWorktreeMainRepoRootMock, getRepoName: vi.fn().mockImplementation((path: string) => path.split('/').pop()), @@ -151,6 +156,8 @@ describe('repos:add with git worktrees', () => { await callAdd({ path: '/Users/dev/notes', kind: 'folder' }) + expect(isGitRepoMock).not.toHaveBeenCalled() + expect(getGitRepoRootMock).not.toHaveBeenCalled() expect(getLinkedWorktreeMainRepoRootMock).not.toHaveBeenCalled() expect(mockStore.addRepo).toHaveBeenCalledTimes(1) }) 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 f4a43633712..8f5ff7dba53 100644 --- a/src/main/ipc/repos-remote-base-ref-queries.test.ts +++ b/src/main/ipc/repos-remote-base-ref-queries.test.ts @@ -107,25 +107,20 @@ describe('repos:getBaseRefDefault envelope', () => { return Promise.reject(new Error(`unexpected exec call: ${argv.join(' ')}`)) } } - const isSymbolicRef = (argv: string[]): boolean => - argv[0] === 'symbolic-ref' && argv.includes('refs/remotes/origin/HEAD') - const isRevParseFor = - (ref: string) => - (argv: string[]): boolean => - argv[0] === 'rev-parse' && argv.includes(ref) + const isRefSnapshot = (argv: string[]): boolean => + argv[0] === 'for-each-ref' && argv.includes('--format=%(refname)%00%(symref)') const isRemoteList = (argv: string[]): boolean => argv.length === 1 && argv[0] === 'remote' it('returns envelope over SSH relay for remote repos', async () => { mockGitProvider.exec = vi.fn().mockImplementation( dispatchExec([ { - matches: isSymbolicRef, - respond: () => Promise.resolve({ stdout: 'refs/remotes/origin/main\n', stderr: '' }) - }, - // origin/HEAD is verified before trusted, so it must also resolve via rev-parse. - { - matches: isRevParseFor('refs/remotes/origin/main'), - respond: () => Promise.resolve({ stdout: '', stderr: '' }) + matches: isRefSnapshot, + respond: () => + Promise.resolve({ + stdout: 'refs/remotes/origin/HEAD\0refs/remotes/origin/main\n', + stderr: '' + }) }, { matches: isRemoteList, @@ -154,13 +149,12 @@ describe('repos:getBaseRefDefault envelope', () => { mockGitProvider.exec = vi.fn().mockImplementation( dispatchExec([ { - matches: isSymbolicRef, - respond: () => Promise.resolve({ stdout: 'refs/remotes/origin/main\n', stderr: '' }) - }, - // origin/HEAD is verified before trusted, so it must also resolve via rev-parse. - { - matches: isRevParseFor('refs/remotes/origin/main'), - respond: () => Promise.resolve({ stdout: '', stderr: '' }) + matches: isRefSnapshot, + respond: () => + Promise.resolve({ + stdout: 'refs/remotes/origin/HEAD\0refs/remotes/origin/main\n', + stderr: '' + }) }, { matches: isRemoteList, @@ -186,20 +180,12 @@ describe('repos:getBaseRefDefault envelope', () => { expect(result.remoteCount).toBe(0) }) - it('falls back through probes over SSH when symbolic-ref fails', async () => { + it('uses the primary fallback over SSH when origin/HEAD is absent', async () => { mockGitProvider.exec = vi.fn().mockImplementation( dispatchExec([ - // symbolic-ref rejects (no origin/HEAD on the remote) - { matches: isSymbolicRef, respond: () => Promise.reject(new Error('no symbolic-ref')) }, - // probe 1: refs/remotes/origin/main — rejects { - matches: isRevParseFor('refs/remotes/origin/main'), - respond: () => Promise.reject(new Error('missing')) - }, - // probe 2: refs/remotes/origin/master — succeeds - { - matches: isRevParseFor('refs/remotes/origin/master'), - respond: () => Promise.resolve({ stdout: 'abc123\n', stderr: '' }) + matches: isRefSnapshot, + respond: () => Promise.resolve({ stdout: 'refs/remotes/origin/master\0\n', stderr: '' }) }, { matches: isRemoteList, @@ -220,7 +206,6 @@ describe('repos:getBaseRefDefault envelope', () => { remoteCount: number } - // Why: when symbolic-ref fails, the probe chain resolves origin/master, matching the local path. expect(result.defaultBaseRef).toBe('origin/master') expect(result.remoteCount).toBe(1) }) diff --git a/src/main/ipc/repos-remote-test-harness.ts b/src/main/ipc/repos-remote-test-harness.ts index 4669e9f9900..075b911ad50 100644 --- a/src/main/ipc/repos-remote-test-harness.ts +++ b/src/main/ipc/repos-remote-test-harness.ts @@ -113,11 +113,18 @@ export function electronModuleMock(mocks: ReposIpcMocks): Record { + const isGitRepo = vi.fn().mockReturnValue(true) + const getGitRepoRoot = vi.fn((path: string) => path) return { ...actual, // Stub only the functions that spawn git / touch the filesystem. - isGitRepo: vi.fn().mockReturnValue(true), - getGitRepoRoot: vi.fn((path: string) => path), + isGitRepo, + getGitRepoRoot, + inspectGitRepoForRegistration: vi.fn((path: string) => ({ + isRepo: isGitRepo(path), + rootPath: getGitRepoRoot(path), + mainRepoPath: null + })), getRepoName: vi.fn().mockImplementation((path: string) => path.split('/').pop()), getBaseRefDefault: vi.fn().mockResolvedValue('origin/main'), getRemoteCount: vi.fn().mockResolvedValue(1), diff --git a/src/main/ipc/repos/local-repo-registration.ts b/src/main/ipc/repos/local-repo-registration.ts index 5fcb816ba98..8a3c2864bb5 100644 --- a/src/main/ipc/repos/local-repo-registration.ts +++ b/src/main/ipc/repos/local-repo-registration.ts @@ -5,12 +5,7 @@ import { isFolderRepo } from '../../../shared/repo-kind' import { DEFAULT_REPO_BADGE_COLOR } from '../../../shared/constants' import { normalizeRuntimePathForComparison } from '../../../shared/cross-platform-path' import { awaitWindowsHostGitEnvironmentReady } from '../../git/runner' -import { - isGitRepo, - getGitRepoRoot, - getLinkedWorktreeMainRepoRoot, - getRepoName -} from '../../git/repo' +import { inspectGitRepoForRegistration, getGitRepoRoot, getRepoName } from '../../git/repo' import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' import { detectRepoIconAndUpstream } from '../../repo-icon-autodetect' import { prepareLocalWorktreeRootForRepo } from '../../worktree-root-preparation' @@ -25,11 +20,12 @@ export async function addLocalRepoFromPath( if (repoKind === 'git') { await awaitWindowsHostGitEnvironmentReady({ cwd: path }) } - if (repoKind === 'git' && !isGitRepo(path)) { + const gitInfo = repoKind === 'git' ? inspectGitRepoForRegistration(path) : null + if (gitInfo && !gitInfo.isRepo) { return { error: `Not a valid git repository: ${path}` } } - const resolvedPath = repoKind === 'git' ? getGitRepoRoot(path) : path + const resolvedPath = gitInfo?.rootPath ?? path const pathKey = normalizeRuntimePathForComparison(path) const existing = store .getRepos() @@ -55,7 +51,7 @@ export async function addLocalRepoFromPath( // it belongs to an already-tracked repo. Adding it anyway yields a second "ready" host setup on the // same project and host — a duplicate run-target row that resolves to a transient worktree path. if (repoKind === 'git') { - const mainRepoRoot = getLinkedWorktreeMainRepoRoot(resolvedPath) + const mainRepoRoot = gitInfo?.mainRepoPath ? getGitRepoRoot(gitInfo.mainRepoPath) : null if (mainRepoRoot) { const mainRepoKey = normalizeRuntimePathForComparison(mainRepoRoot) // Why !isFolderRepo: only a git-kind main checkout projects onto the same project as its diff --git a/src/main/ipc/repos/repo-clone-lifecycle.ts b/src/main/ipc/repos/repo-clone-lifecycle.ts index b6531fdd2b3..0fa48ff3667 100644 --- a/src/main/ipc/repos/repo-clone-lifecycle.ts +++ b/src/main/ipc/repos/repo-clone-lifecycle.ts @@ -8,7 +8,7 @@ import type { Repo } from '../../../shared/repo-types' import { isFolderRepo } from '../../../shared/repo-kind' import { DEFAULT_REPO_BADGE_COLOR } from '../../../shared/constants' import { getGitCloneFailureMessage } from '../../../shared/git-clone-failure-message' -import { gitSpawnAfterWindowsEnvironmentReady, nonInteractiveGitEnv } from '../../git/runner' +import { gitSpawnAfterWindowsEnvironmentReady, promptGuardGitEnv } from '../../git/runner' import { getRepoName } from '../../git/repo' import type { ClaimedCloneTarget } from '../../git/repo-clone-path' import { @@ -148,7 +148,7 @@ export function registerRepoCloneHandlers(mainWindow: BrowserWindow, store: Stor cwd: args.destination, admissionTier: 'interactive', // Why: without this, an auth-needing clone pops Git Credential Manager's OAuth window on Windows, unclosable in a restricted env (issue #7652). - env: nonInteractiveGitEnv(), + env: promptGuardGitEnv(), signal: pendingController.signal, stdio: ['ignore', 'ignore', 'pipe'] } diff --git a/src/main/ipc/worktree-remote-ssh-branch-conflict.test.ts b/src/main/ipc/worktree-remote-ssh-branch-conflict.test.ts index e3e24e96a08..1fb34d59663 100644 --- a/src/main/ipc/worktree-remote-ssh-branch-conflict.test.ts +++ b/src/main/ipc/worktree-remote-ssh-branch-conflict.test.ts @@ -10,6 +10,9 @@ function providerAnswering(answers: { remoteRefs: string[] }): ConflictProvider { const exec: ConflictProvider['exec'] = vi.fn(async (args: string[]) => { + if (args[0] === 'for-each-ref') { + return { stdout: '', stderr: '' } + } if (args[0] === 'remote') { return { stdout: `${answers.remotes.join('\n')}\n`, stderr: '' } } diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 05396e16946..902f9203b55 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -534,10 +534,6 @@ function getSshWorktreeCreateBaseFetchKey(repo: Repo, base: RemoteTrackingBase): return `${repo.connectionId ?? 'ssh'}::${repo.path}::base:${base.remote}:${base.branch}` } -function getSshWorktreeCreateRemoteFetchKey(repo: Repo, remote: string): string { - return `${repo.connectionId ?? 'ssh'}::${repo.path}::remote:${remote}` -} - function getSshWorktreeCreateRemoteQueueKey(repo: Repo, remote: string): string { return `${repo.connectionId ?? 'ssh'}::${repo.path}::queue:${remote}` } @@ -630,13 +626,17 @@ async function refreshRemoteTrackingBaseForWorktreeCreate( async function fetchRemoteForWorktreeCreate( provider: SshGitProvider, repo: Repo, - remote: string -): Promise { - return getOrStartSshWorktreeCreateFetch( - getSshWorktreeCreateRemoteFetchKey(repo, remote), - getSshWorktreeCreateRemoteQueueKey(repo, remote), - () => provider.exec(['fetch', remote], repo.path).then(() => undefined) - ) + baseBranch: string +): Promise { + const branch = normalizeLocalBranchName(baseBranch) + const base = { + remote: 'origin', + branch, + ref: `refs/remotes/origin/${branch}`, + base: `origin/${branch}` + } + await refreshRemoteTrackingBaseForWorktreeCreate(provider, repo, base) + return base } export function __resetSshWorktreeCreateFetchCacheForTests(): void { @@ -1630,7 +1630,7 @@ export async function prefetchRemoteWorktreeCreateBase( } // Why: mirrors createRemoteWorktree's legacy local-base fallback so prefetch and create share one process-local SSH fetch cache. - await fetchRemoteForWorktreeCreate(provider, repo, 'origin') + await fetchRemoteForWorktreeCreate(provider, repo, basePlan.baseBranch) } /** Never rejects: the create may already have succeeded when this settles. */ @@ -1921,9 +1921,13 @@ export async function createRemoteWorktree( } } } else if (!(await hasRemoteWorktreeBaseRef(provider, repo.path, baseBranch))) { - // Why: non-remote-tracking bases keep the legacy best-effort fetch; verified PR/MR SHA bases already have the object, so a broad fetch is wasted. + // Why: fetch only the missing named base; verified PR/MR SHA bases already have the object. try { - await fetchRemoteForWorktreeCreate(provider, repo, 'origin') + const fetchedBase = await fetchRemoteForWorktreeCreate(provider, repo, baseBranch) + if (await hasCommitRefSsh(provider, repo.path, fetchedBase.ref)) { + baseBranch = fetchedBase.base + remoteTrackingBase = fetchedBase + } } catch { /* best-effort */ } 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 1a32ce36a55..da5eeb64aba 100644 --- a/src/main/ipc/worktrees-ssh-base-ref-resolution.test.ts +++ b/src/main/ipc/worktrees-ssh-base-ref-resolution.test.ts @@ -107,6 +107,9 @@ describe('registerWorktreeHandlers', () => { const setupError = new Error('sparse init failed') const provider = { exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'for-each-ref' && args.includes('refs/heads/')) { + return { stdout: '', stderr: '' } + } if (args[0] === 'remote') { return { stdout: 'origin\n', stderr: '' } } @@ -168,6 +171,9 @@ describe('registerWorktreeHandlers', () => { } const provider = { exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'for-each-ref' && args.includes('refs/heads/')) { + return { stdout: '', stderr: '' } + } if (args[0] === 'remote') { return { stdout: 'origin\n', stderr: '' } } @@ -226,6 +232,9 @@ describe('registerWorktreeHandlers', () => { } const provider = { exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'for-each-ref' && args.includes('refs/heads/')) { + return { stdout: '', stderr: '' } + } if (args[0] === 'remote') { return { stdout: 'origin\n', stderr: '' } } @@ -301,6 +310,9 @@ describe('registerWorktreeHandlers', () => { let repoRootRegistered = false const provider = { exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'for-each-ref' && args.includes('refs/heads/')) { + return { stdout: '', stderr: '' } + } if (args[0] === 'config') { return { stdout: '', stderr: '' } } @@ -376,6 +388,9 @@ describe('registerWorktreeHandlers', () => { let repoRootRegistered = false const provider = { exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'for-each-ref' && args.includes('refs/heads/')) { + return { stdout: '', stderr: '' } + } if (args[0] === 'config') { return { stdout: '', stderr: '' } } 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 2d300fa6f60..03e69583e99 100644 --- a/src/main/ipc/worktrees-ssh-branch-conflict-suffixing.test.ts +++ b/src/main/ipc/worktrees-ssh-branch-conflict-suffixing.test.ts @@ -105,6 +105,9 @@ describe('registerWorktreeHandlers', () => { } const provider = { exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'for-each-ref') { + return { stdout: '', stderr: '' } + } if (args[0] === 'remote') { return { stdout: 'origin\n', stderr: '' } } @@ -200,6 +203,9 @@ describe('registerWorktreeHandlers', () => { } const provider = { exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'for-each-ref') { + return { stdout: '', stderr: '' } + } if (args[0] === 'remote') { return { stdout: 'origin\n', stderr: '' } } @@ -269,6 +275,9 @@ describe('registerWorktreeHandlers', () => { } const provider = { exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'for-each-ref') { + return { stdout: '', stderr: '' } + } if (args[0] === 'remote') { return { stdout: 'origin\nfoo/bar\n', stderr: '' } } 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 6b09ce2a743..0994cdc2460 100644 --- a/src/main/ipc/worktrees-ssh-create-base-prefetch.test.ts +++ b/src/main/ipc/worktrees-ssh-create-base-prefetch.test.ts @@ -93,6 +93,83 @@ describe('registerWorktreeHandlers', () => { setupWorktreeHandlers() }) + it('fetches a missing slash-named SSH base through the narrow RPC and uses the verified tracking ref', async () => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1' + } + let fetched = false + const provider = { + exec: vi.fn(async (args: string[]) => { + if (args[0] === 'fetch') { + throw new Error('generic fetch is forbidden') + } + if (args[0] === 'for-each-ref' && args.includes('refs/heads/')) { + return { stdout: '', stderr: '' } + } + if (args[0] === 'remote') { + return { stdout: 'origin\n', stderr: '' } + } + if (args[0] === 'show-ref') { + throw missingShowRefError() + } + if (args[0] === 'rev-parse') { + if (fetched && args.includes('refs/remotes/origin/release/mobile^{commit}')) { + return { stdout: 'verified-sha\n', stderr: '' } + } + throw missingShowRefError() + } + return { stdout: '', stderr: '' } + }), + fetchRemoteTrackingRef: vi.fn(async () => { + fetched = true + }), + addWorktree: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/remote/repo-missing-base', + head: 'verified-sha', + branch: 'refs/heads/missing-base', + isBare: false, + isMainWorktree: false + } + ]) + } + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + getSshGitProviderMock.mockReturnValue(provider) + getActiveMultiplexerMock.mockReturnValue({ + request: vi.fn().mockResolvedValue(undefined), + notify: vi.fn() + }) + store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + + await handlers['worktrees:create'](null, { + repoId: repo.id, + name: 'missing-base', + baseBranch: 'release/mobile' + }) + + expect(provider.fetchRemoteTrackingRef).toHaveBeenCalledWith( + repo.path, + 'origin', + 'release/mobile', + 'refs/remotes/origin/release/mobile', + { skipAutoMaintenance: true } + ) + expect(provider.exec.mock.calls.some(([args]) => args[0] === 'fetch')).toBe(false) + expect(provider.addWorktree).toHaveBeenCalledWith( + repo.path, + 'missing-base', + '/remote/repo-missing-base', + { base: 'origin/release/mobile' } + ) + }) + it('reuses a fresh SSH remote-tracking base refresh for repeated creates', async () => { const repo = { id: 'repo-ssh', @@ -105,6 +182,9 @@ describe('registerWorktreeHandlers', () => { } const provider = { exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'for-each-ref' && args.includes('refs/heads/')) { + return { stdout: '', stderr: '' } + } if (args[0] === 'remote') { return { stdout: 'origin\n', stderr: '' } } @@ -179,6 +259,9 @@ describe('registerWorktreeHandlers', () => { } const provider = { exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'for-each-ref' && args.includes('refs/heads/')) { + return { stdout: '', stderr: '' } + } if (args[0] === 'remote') { return { stdout: 'origin\n', stderr: '' } } @@ -251,6 +334,9 @@ describe('registerWorktreeHandlers', () => { }) const provider = { exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'for-each-ref' && args.includes('refs/heads/')) { + return { stdout: '', stderr: '' } + } if (args[0] === 'remote') { return { stdout: 'origin\n', stderr: '' } } @@ -317,6 +403,9 @@ describe('registerWorktreeHandlers', () => { const events: string[] = [] const provider = { exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'for-each-ref' && args.includes('refs/heads/')) { + return { stdout: '', stderr: '' } + } events.push(`exec:${args[0]}:${registeredRoots.has('/remote/repo')}`) if (!registeredRoots.has('/remote/repo')) { throw new Error('root not registered') @@ -397,6 +486,9 @@ describe('registerWorktreeHandlers', () => { } const provider = { exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'for-each-ref' && args.includes('refs/heads/')) { + return { stdout: '', stderr: '' } + } if (args[0] === 'symbolic-ref') { return { stdout: 'refs/remotes/origin/main\n', stderr: '' } } @@ -479,6 +571,9 @@ describe('registerWorktreeHandlers', () => { }) const provider = { exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'for-each-ref' && args.includes('refs/heads/')) { + return { stdout: '', stderr: '' } + } if (args[0] === 'remote') { return pendingRemoteList } @@ -548,6 +643,9 @@ describe('registerWorktreeHandlers', () => { }) const provider = { exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'for-each-ref' && args.includes('refs/heads/')) { + return { stdout: '', stderr: '' } + } if (args[0] === 'remote') { return { stdout: 'origin\n', stderr: '' } } @@ -595,7 +693,13 @@ describe('registerWorktreeHandlers', () => { resolveExactFetch() await vi.waitFor(() => - expect(provider.exec.mock.calls.filter(([args]) => args[0] === 'fetch')).toHaveLength(1) + expect(provider.fetchRemoteTrackingRef).toHaveBeenCalledWith( + '/remote/repo', + 'origin', + 'local-base', + 'refs/remotes/origin/local-base', + { skipAutoMaintenance: true } + ) ) await prefetch await create diff --git a/src/main/ipc/worktrees-ssh-local-base-refresh-overlap.test.ts b/src/main/ipc/worktrees-ssh-local-base-refresh-overlap.test.ts index 59794f82ba7..f6aa29b387e 100644 --- a/src/main/ipc/worktrees-ssh-local-base-refresh-overlap.test.ts +++ b/src/main/ipc/worktrees-ssh-local-base-refresh-overlap.test.ts @@ -108,6 +108,9 @@ function createProvider(overrides: { }) { return { exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'for-each-ref') { + return { stdout: '', stderr: '' } + } if (args[0] === 'remote') { return { stdout: 'origin\n', stderr: '' } } @@ -208,6 +211,9 @@ describe('SSH local base refresh overlap', () => { it('does not refresh or warn when the create makes the local base branch itself', async () => { const provider = createProvider({ refreshLocalBaseRefForWorktreeCreate: vi.fn() }) provider.exec.mockImplementation(async (args: string[]) => { + if (args[0] === 'for-each-ref') { + return { stdout: '', stderr: '' } + } if (args[0] === 'remote') { return { stdout: 'origin\n', stderr: '' } } 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 2d8023c9a8e..f836988c2c8 100644 --- a/src/main/ipc/worktrees-ssh-local-base-refresh.test.ts +++ b/src/main/ipc/worktrees-ssh-local-base-refresh.test.ts @@ -105,6 +105,9 @@ function createProvider(relay: { }) { return { exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'for-each-ref') { + return { stdout: '', stderr: '' } + } if (args[0] === 'remote') { return { stdout: 'origin\n', stderr: '' } } diff --git a/src/main/ipc/worktrees/removal/register-worktree-forget-handlers.ts b/src/main/ipc/worktrees/removal/register-worktree-forget-handlers.ts index 0c97dda68bd..88f99e27d83 100644 --- a/src/main/ipc/worktrees/removal/register-worktree-forget-handlers.ts +++ b/src/main/ipc/worktrees/removal/register-worktree-forget-handlers.ts @@ -200,7 +200,8 @@ export function registerWorktreeForgetHandlers(context: WorktreeIpcContext): voi repo.path, cleanupTarget.branchName, cleanupTarget.head, - (argv, cwd) => gitExecFileAsync(argv, { cwd, ...localWorktreeGitOptions }) + (argv, cwd) => gitExecFileAsync(argv, { cwd, ...localWorktreeGitOptions }), + localWorktreeGitOptions ) : forceDeleteLocalBranch(repo.path, cleanupTarget.branchName, cleanupTarget.head)) await cleanupUnusedWorktreePushTargetRemote( diff --git a/src/main/providers/working-directory-validation.ts b/src/main/providers/working-directory-validation.ts index cf098cad2c3..7f9ec89a9e8 100644 --- a/src/main/providers/working-directory-validation.ts +++ b/src/main/providers/working-directory-validation.ts @@ -5,7 +5,7 @@ import { existsSync, statSync } from 'node:fs' import { stat } from 'node:fs/promises' import { release } from 'node:os' -import { isWslUncPath, parseWslUncPath } from '../../shared/wsl-paths' +import { isWslUncPath, uncRouteKey } from '../../shared/wsl-paths' import { wslUncDirectoryExists, wslUncDirectoryExistsAsync } from '../wsl' import { PrioritySemaphore } from '../../shared/priority-semaphore' @@ -24,22 +24,7 @@ type UncRouteLane = { const uncRouteLanes = new Map() -/** - * Groups paths by the host that must answer for them, so many dead - * subdirectories of one share share a lane. Returns null for local-disk paths, - * which never block long enough to be worth queueing. - */ -export function uncRouteKey(cwd: string): string | null { - if (!cwd.startsWith('\\\\')) { - return null - } - const wslInfo = parseWslUncPath(cwd) - if (wslInfo) { - return `wsl:${wslInfo.distro.trim().toLowerCase()}` - } - const server = cwd.slice(2).split(/[\\/]/, 1)[0] - return `unc:${server.toLowerCase()}` -} +export { uncRouteKey } from '../../shared/wsl-paths' async function withUncRouteLane(cwd: string, run: () => Promise): Promise { const key = uncRouteKey(cwd) diff --git a/src/main/runtime/orca-runtime-test-fixtures.spec.ts b/src/main/runtime/orca-runtime-test-fixtures.spec.ts index 78a6089e3ed..63a29dbc717 100644 --- a/src/main/runtime/orca-runtime-test-fixtures.spec.ts +++ b/src/main/runtime/orca-runtime-test-fixtures.spec.ts @@ -150,6 +150,15 @@ const RESTORED_AUTHORITY_TOKEN_HASH = createHash('sha256') .update(RESTORED_AUTHORITY_TOKEN) .digest('hex') +function isLocalBranchCatalogQuery(args: string[]): boolean { + return ( + args.length === 3 && + args[0] === 'for-each-ref' && + args[1] === '--format=%(refname)' && + args[2] === 'refs/heads/' + ) +} + function isOriginMainBaseRefProbe(args: string[]): boolean { return ( args[0] === 'rev-parse' && @@ -671,6 +680,7 @@ export { antigravityPromptBeforeModelReadyScreen, antigravityReadyScreen, bindSi export { createExplicitAgentStatusHarness, createFolderWorkspaceRuntimeStore, createRuntime } export { createRuntimeWithSshLease, createStaleRuntimeWorktreeStore, cursorBusyScreen } export { cursorReadyScreen, deferred, expectStablePaneKeyEnv, isOriginMainBaseRefProbe } +export { isLocalBranchCatalogQuery } export { makeDeferred, makeFolderProjectGroup, makeFolderWorkspace, makeHeadlessTerminalLayout } export { makeRpcRequest, makeRuntimeStoreWithWorkspaceSession, makeStatusFrame } export { makeWorkspaceSessionWithHeadlessTerminal, makeWorktreeInfo, makeWorktreeMeta } diff --git a/src/main/runtime/orca-runtime-tests/hooks-and-hosted-review-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/hooks-and-hosted-review-part-03.spec.ts index dae7c29f94b..4ff812e3ba7 100644 --- a/src/main/runtime/orca-runtime-tests/hooks-and-hosted-review-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/hooks-and-hosted-review-part-03.spec.ts @@ -34,8 +34,8 @@ describe('OrcaRuntimeService', () => { const wslGitOptions = { cwd: TEST_REPO_PATH, wslDistro: 'Ubuntu' } let driftCounts = '1\t2\n' const asyncGitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => { - if (args[0] === 'symbolic-ref') { - return { stdout: 'refs/remotes/origin/main\n', stderr: '' } + if (args[0] === 'for-each-ref' && args.includes('--format=%(refname)%00%(symref)')) { + return { stdout: 'refs/remotes/origin/HEAD\0refs/remotes/origin/main\n', stderr: '' } } if (isOriginMainBaseRefProbe(args)) { return { stdout: 'main-sha\n', stderr: '' } @@ -67,7 +67,15 @@ describe('OrcaRuntimeService', () => { recentSubjects: ['base commit 2', 'base commit 1'] }) expect(asyncGitSpy).toHaveBeenCalledWith( - ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], + [ + 'for-each-ref', + '--format=%(refname)%00%(symref)', + 'refs/remotes/origin/HEA[D]', + 'refs/remotes/origin/mai[n]', + 'refs/remotes/origin/maste[r]', + 'refs/heads/mai[n]', + 'refs/heads/maste[r]' + ], { ...wslGitOptions, timeout: 15_000 } ) expect(asyncGitSpy).toHaveBeenCalledWith(['remote'], wslGitOptions) @@ -84,7 +92,7 @@ describe('OrcaRuntimeService', () => { { cwd: TEST_WORKTREE_PATH, wslDistro: 'Ubuntu', timeout: 15_000 } ) expect(asyncGitSpy).toHaveBeenCalledWith( - ['log', '--format=%s', '-n', '5', 'HEAD..origin/main'], + ['log', '--no-show-signature', '--no-color', '--format=%s', '-n', '5', 'HEAD..origin/main'], { cwd: TEST_WORKTREE_PATH, wslDistro: 'Ubuntu', timeout: 15_000 } ) diff --git a/src/main/runtime/orca-runtime-tests/ssh-worktree-lifecycle-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/ssh-worktree-lifecycle-part-02.spec.ts index a532cd45f10..5394bbacb85 100644 --- a/src/main/runtime/orca-runtime-tests/ssh-worktree-lifecycle-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/ssh-worktree-lifecycle-part-02.spec.ts @@ -26,6 +26,7 @@ import type { WorktreeMeta } from '../orca-runtime-test-mocks.spec' import { TEST_REPO_ID, TEST_REPO_PATH, + isLocalBranchCatalogQuery, isOriginMainBaseRefProbe, makeWorktreeMeta, store, @@ -78,11 +79,11 @@ describe('OrcaRuntimeService', () => { if (args[0] === 'config') { return { stdout: 'Remote User\n', stderr: '' } } - if (args[0] === 'branch') { + if (args[0] === 'branch' || isLocalBranchCatalogQuery(args)) { return { stdout: '', stderr: '' } } - if (args[0] === 'symbolic-ref') { - return { stdout: 'origin/main\n', stderr: '' } + if (args[0] === 'for-each-ref' && args.includes('--format=%(refname)%00%(symref)')) { + return { stdout: 'refs/remotes/origin/HEAD\0refs/remotes/origin/main\n', stderr: '' } } if (isOriginMainBaseRefProbe(args)) { return { stdout: 'main-sha\n', stderr: '' } @@ -248,11 +249,11 @@ describe('OrcaRuntimeService', () => { if (args[0] === 'config') { return { stdout: 'Remote User\n', stderr: '' } } - if (args[0] === 'branch') { + if (args[0] === 'branch' || isLocalBranchCatalogQuery(args)) { return { stdout: '', stderr: '' } } - if (args[0] === 'symbolic-ref') { - return { stdout: 'origin/main\n', stderr: '' } + if (args[0] === 'for-each-ref' && args.includes('--format=%(refname)%00%(symref)')) { + return { stdout: 'refs/remotes/origin/HEAD\0refs/remotes/origin/main\n', stderr: '' } } if (isOriginMainBaseRefProbe(args)) { return { stdout: 'main-sha\n', stderr: '' } diff --git a/src/main/runtime/orca-runtime-tests/ssh-worktree-lifecycle.spec.ts b/src/main/runtime/orca-runtime-tests/ssh-worktree-lifecycle.spec.ts index 0d9886aade6..8b3e0a4d8ab 100644 --- a/src/main/runtime/orca-runtime-tests/ssh-worktree-lifecycle.spec.ts +++ b/src/main/runtime/orca-runtime-tests/ssh-worktree-lifecycle.spec.ts @@ -22,6 +22,7 @@ import { TEST_REPO_ID, TEST_REPO_PATH, createFolderWorkspaceRuntimeStore, + isLocalBranchCatalogQuery, isOriginMainBaseRefProbe, makeWorktreeMeta, store @@ -93,11 +94,11 @@ describe('OrcaRuntimeService', () => { if (args[0] === 'config') { return { stdout: 'Remote User\n', stderr: '' } } - if (args[0] === 'branch') { + if (args[0] === 'branch' || isLocalBranchCatalogQuery(args)) { return { stdout: '', stderr: '' } } - if (args[0] === 'symbolic-ref') { - return { stdout: 'origin/main\n', stderr: '' } + if (args[0] === 'for-each-ref' && args.includes('--format=%(refname)%00%(symref)')) { + return { stdout: 'refs/remotes/origin/HEAD\0refs/remotes/origin/main\n', stderr: '' } } if (isOriginMainBaseRefProbe(args)) { return { stdout: 'main-sha\n', stderr: '' } @@ -200,11 +201,11 @@ describe('OrcaRuntimeService', () => { if (args[0] === 'config') { return { stdout: 'Remote User\n', stderr: '' } } - if (args[0] === 'branch') { + if (args[0] === 'branch' || isLocalBranchCatalogQuery(args)) { return { stdout: '', stderr: '' } } - if (args[0] === 'symbolic-ref') { - return { stdout: 'origin/main\n', stderr: '' } + if (args[0] === 'for-each-ref' && args.includes('--format=%(refname)%00%(symref)')) { + return { stdout: 'refs/remotes/origin/HEAD\0refs/remotes/origin/main\n', stderr: '' } } if (isOriginMainBaseRefProbe(args)) { return { stdout: 'main-sha\n', stderr: '' } @@ -405,11 +406,11 @@ describe('OrcaRuntimeService', () => { if (args[0] === 'config') { return { stdout: 'Remote User\n', stderr: '' } } - if (args[0] === 'branch') { + if (args[0] === 'branch' || isLocalBranchCatalogQuery(args)) { return { stdout: '', stderr: '' } } - if (args[0] === 'symbolic-ref') { - return { stdout: 'origin/main\n', stderr: '' } + if (args[0] === 'for-each-ref' && args.includes('--format=%(refname)%00%(symref)')) { + return { stdout: 'refs/remotes/origin/HEAD\0refs/remotes/origin/main\n', stderr: '' } } if (isOriginMainBaseRefProbe(args)) { return { stdout: 'main-sha\n', stderr: '' } @@ -518,11 +519,11 @@ describe('OrcaRuntimeService', () => { if (args[0] === 'config') { return { stdout: 'Remote User\n', stderr: '' } } - if (args[0] === 'branch') { + if (args[0] === 'branch' || isLocalBranchCatalogQuery(args)) { return { stdout: '', stderr: '' } } - if (args[0] === 'symbolic-ref') { - return { stdout: 'origin/main\n', stderr: '' } + if (args[0] === 'for-each-ref' && args.includes('--format=%(refname)%00%(symref)')) { + return { stdout: 'refs/remotes/origin/HEAD\0refs/remotes/origin/main\n', stderr: '' } } if (isOriginMainBaseRefProbe(args)) { return { stdout: 'main-sha\n', stderr: '' } diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts index 79ff0be6698..2d1af59155b 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts @@ -333,8 +333,8 @@ describe('OrcaRuntimeService', () => { ensurePathWithinWorkspaceMock.mockReturnValue(createdWorktree.path) vi.mocked(describeCreatedWorktree).mockResolvedValue(createdWorktree) const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => { - if (args[0] === 'symbolic-ref') { - return { stdout: 'refs/remotes/origin/main\n', stderr: '' } + if (args[0] === 'for-each-ref' && args.includes('--format=%(refname)%00%(symref)')) { + return { stdout: 'refs/remotes/origin/HEAD\0refs/remotes/origin/main\n', stderr: '' } } if (args[0] === 'rev-parse' && args.includes('refs/heads/runtime-wsl^{commit}')) { throw new Error('missing local branch') @@ -370,11 +370,18 @@ describe('OrcaRuntimeService', () => { path: createdWorktree.path, branch: 'refs/heads/runtime-wsl' }) - expect(gitSpy).toHaveBeenCalledWith(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], { - cwd: TEST_REPO_PATH, - timeout: 15_000, - wslDistro: 'Ubuntu' - }) + expect(gitSpy).toHaveBeenCalledWith( + [ + 'for-each-ref', + '--format=%(refname)%00%(symref)', + 'refs/remotes/origin/HEA[D]', + 'refs/remotes/origin/mai[n]', + 'refs/remotes/origin/maste[r]', + 'refs/heads/mai[n]', + 'refs/heads/maste[r]' + ], + { cwd: TEST_REPO_PATH, timeout: 15_000, wslDistro: 'Ubuntu' } + ) expect(getBranchConflictKind).toHaveBeenCalledWith( TEST_REPO_PATH, 'runtime-wsl', diff --git a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-05.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-05.spec.ts index 1658c8690c7..e23d82ddaf8 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-05.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-05.spec.ts @@ -13,6 +13,7 @@ import { import type { WorktreeMeta } from '../orca-runtime-test-mocks.spec' import { TEST_REPO_ID, + isLocalBranchCatalogQuery, isOriginMainBaseRefProbe, makeWorktreeMeta, store @@ -67,11 +68,11 @@ describe('OrcaRuntimeService', () => { if (args[0] === 'config') { return { stdout: 'Remote User\n', stderr: '' } } - if (args[0] === 'branch') { + if (args[0] === 'branch' || isLocalBranchCatalogQuery(args)) { return { stdout: '', stderr: '' } } - if (args[0] === 'symbolic-ref') { - return { stdout: 'origin/main\n', stderr: '' } + if (args[0] === 'for-each-ref' && args.includes('--format=%(refname)%00%(symref)')) { + return { stdout: 'refs/remotes/origin/HEAD\0refs/remotes/origin/main\n', stderr: '' } } if (isOriginMainBaseRefProbe(args)) { return { stdout: 'main-sha\n', stderr: '' } @@ -170,11 +171,11 @@ describe('OrcaRuntimeService', () => { if (args[0] === 'config') { return { stdout: 'Remote User\n', stderr: '' } } - if (args[0] === 'branch') { + if (args[0] === 'branch' || isLocalBranchCatalogQuery(args)) { return { stdout: '', stderr: '' } } - if (args[0] === 'symbolic-ref') { - return { stdout: 'origin/main\n', stderr: '' } + if (args[0] === 'for-each-ref' && args.includes('--format=%(refname)%00%(symref)')) { + return { stdout: 'refs/remotes/origin/HEAD\0refs/remotes/origin/main\n', stderr: '' } } if (isOriginMainBaseRefProbe(args)) { return { stdout: 'main-sha\n', stderr: '' } @@ -276,11 +277,11 @@ describe('OrcaRuntimeService', () => { if (args[0] === 'config') { return { stdout: 'Remote User\n', stderr: '' } } - if (args[0] === 'branch') { + if (args[0] === 'branch' || isLocalBranchCatalogQuery(args)) { return { stdout: '', stderr: '' } } - if (args[0] === 'symbolic-ref') { - return { stdout: 'origin/main\n', stderr: '' } + if (args[0] === 'for-each-ref' && args.includes('--format=%(refname)%00%(symref)')) { + return { stdout: 'refs/remotes/origin/HEAD\0refs/remotes/origin/main\n', stderr: '' } } if (isOriginMainBaseRefProbe(args)) { return { stdout: 'main-sha\n', stderr: '' } diff --git a/src/main/runtime/repo-worktree-admin-fingerprint.test.ts b/src/main/runtime/repo-worktree-admin-fingerprint.test.ts index 84b91f9c12f..3e0bc9526e3 100644 --- a/src/main/runtime/repo-worktree-admin-fingerprint.test.ts +++ b/src/main/runtime/repo-worktree-admin-fingerprint.test.ts @@ -88,6 +88,17 @@ describe('readRepoWorktreeAdminFingerprint', () => { expect(await fingerprint()).not.toBe(before) }) + it('changes when an existing worktree lock is replaced', async () => { + await git( + ['worktree', 'lock', '--reason', 'orca-create-preparation:v1:12345:lease', worktreePath], + repoPath + ) + const before = await fingerprint() + const gitDir = (await git(['rev-parse', '--absolute-git-dir'], worktreePath)).trim() + await writeFile(join(gitDir, 'locked'), 'different user-owned lock\n') + expect(await fingerprint()).not.toBe(before) + }) + it('changes when a linked worktree switches branch', async () => { const before = await fingerprint() // A longer ref name keeps the difference visible even on a coarse mtime clock. diff --git a/src/main/runtime/repo-worktree-admin-fingerprint.ts b/src/main/runtime/repo-worktree-admin-fingerprint.ts index 1c3cf103e67..bd05a053d79 100644 --- a/src/main/runtime/repo-worktree-admin-fingerprint.ts +++ b/src/main/runtime/repo-worktree-admin-fingerprint.ts @@ -1,5 +1,6 @@ import { readdir, readFile, stat } from 'node:fs/promises' import path from 'node:path' +import { resolveGitCommonDirectory } from '../../shared/git-common-directory' import { mapWithConcurrency } from '../../shared/map-with-concurrency' // NUL can appear in neither a path nor a Git ref, so field boundaries stay unambiguous. @@ -22,7 +23,7 @@ const LINKED_WORKTREE_PROBE_CONCURRENCY = 8 */ export async function readRepoWorktreeAdminFingerprint(repoPath: string): Promise { try { - const commonDir = await resolveGitCommonDir(repoPath) + const commonDir = await resolveGitCommonDirectory(repoPath) if (!commonDir) { return null } @@ -58,7 +59,7 @@ async function readLinkedWorktreeStamp( const gitdirTarget = await readTrimmedFile(path.join(entryDir, 'gitdir')) const [head, locked, worktreeExists] = await Promise.all([ readHeadStamp(commonDir, entryDir), - readExistenceStamp(path.join(entryDir, 'locked')), + readFileStamp(path.join(entryDir, 'locked')), // Deleting a worktree directory outside Orca flips its `prunable` row without touching the admin dir. gitdirTarget ? readExistenceStamp(path.dirname(gitdirTarget)) : Promise.resolve(MISSING) ]) @@ -114,41 +115,6 @@ async function readLinkedWorktreeNames(adminDir: string): Promise { } } -async function resolveGitCommonDir(repoPath: string): Promise { - const gitDir = await resolveGitDir(repoPath) - if (!gitDir) { - return null - } - // A linked worktree's gitdir points at the shared admin root through `commondir`. - const commonDir = await readTrimmedFile(path.join(gitDir, 'commondir')) - return commonDir ? path.resolve(gitDir, commonDir) : gitDir -} - -async function resolveGitDir(repoPath: string): Promise { - const dotGitPath = path.join(repoPath, '.git') - let dotGitStats: Awaited> | null = null - try { - dotGitStats = await stat(dotGitPath) - } catch (err) { - if (!isMissingEntryError(err)) { - throw err - } - } - if (!dotGitStats) { - // Bare repo, or a repo path that already is a gitdir. - return (await readExistenceStamp(path.join(repoPath, 'HEAD'))) === 'y' ? repoPath : null - } - if (dotGitStats.isDirectory()) { - return dotGitPath - } - if (!dotGitStats.isFile()) { - return null - } - const contents = await readTrimmedFile(dotGitPath) - const match = contents?.match(/^gitdir:\s*(.+?)\s*$/m) - return match ? path.resolve(repoPath, match[1]) : null -} - async function readTrimmedFile(filePath: string): Promise { try { const trimmed = (await readFile(filePath, 'utf-8')).trim() diff --git a/src/main/runtime/runtime-git-status-admission.test.ts b/src/main/runtime/runtime-git-status-admission.test.ts index 8f7525b74f6..b6c92fe7002 100644 --- a/src/main/runtime/runtime-git-status-admission.test.ts +++ b/src/main/runtime/runtime-git-status-admission.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import type { GitAdmissionEvent } from '../git/command-runner/git-admission-state' +import type { GitAdmissionEvent } from '../../shared/git-admission-state' import { GitAdmissionScheduler } from '../git/command-runner/git-subprocess-admission' import type * as GitStatusModule from '../git/status' import type { OrcaRuntimeService } from './orca-runtime' diff --git a/src/main/runtime/runtime-preserved-branch-cleanup.ts b/src/main/runtime/runtime-preserved-branch-cleanup.ts index 1f5d4f35568..8ba090cf1ef 100644 --- a/src/main/runtime/runtime-preserved-branch-cleanup.ts +++ b/src/main/runtime/runtime-preserved-branch-cleanup.ts @@ -136,8 +136,12 @@ export class RuntimePreservedBranchCleanup { } else { const options = getLocalProjectWorktreeGitOptions(store, repo) await (Object.keys(options).length > 0 - ? forceDeleteLocalBranch(repo.path, target.branchName, target.head, (argv, cwd) => - gitExecFileAsync(argv, { cwd, ...options }) + ? forceDeleteLocalBranch( + repo.path, + target.branchName, + target.head, + (argv, cwd) => gitExecFileAsync(argv, { cwd, ...options }), + options ) : forceDeleteLocalBranch(repo.path, target.branchName, target.head)) await cleanupUnusedWorktreePushTargetRemote( diff --git a/src/main/runtime/runtime-repository-clone-controller.ts b/src/main/runtime/runtime-repository-clone-controller.ts index 1c65e414462..8c3b9b90566 100644 --- a/src/main/runtime/runtime-repository-clone-controller.ts +++ b/src/main/runtime/runtime-repository-clone-controller.ts @@ -10,7 +10,7 @@ import { deriveValidatedClonePath, getClonePathComparisonKey } from '../git/repo-clone-path' -import { gitSpawnAfterWindowsEnvironmentReady, nonInteractiveGitEnv } from '../git/runner' +import { gitSpawnAfterWindowsEnvironmentReady, promptGuardGitEnv } from '../git/runner' import { runWithGitReadCacheInvalidation } from '../git/status' import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth' import { isFolderRepo } from '../../shared/repo-kind' @@ -106,7 +106,7 @@ export class RuntimeRepositoryCloneController { { cwd: trimmedDestination, admissionTier: 'interactive', - env: nonInteractiveGitEnv(), + env: promptGuardGitEnv(), stdio: ['ignore', 'ignore', 'pipe'] } ) 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 888e07a615d..205df659b2e 100644 --- a/src/main/source-control/hosted-review-creation-eligibility.test.ts +++ b/src/main/source-control/hosted-review-creation-eligibility.test.ts @@ -546,7 +546,9 @@ describe('getHostedReviewCreationEligibility', () => { if (args[0] === 'status') { return { stdout: '', stderr: '' } } - // symbolic-ref / for-each-ref resolve the base on the remote. + if (args[0] === 'for-each-ref' && args.includes('--format=%(refname)%00%(symref)')) { + return { stdout: 'refs/remotes/origin/HEAD\0refs/remotes/origin/main\n', stderr: '' } + } return { stdout: 'refs/remotes/origin/main\n', stderr: '' } }) @@ -582,23 +584,25 @@ describe('getHostedReviewCreationEligibility', () => { }) const mockRefs = (opts: { - symbolicRef?: string - forEachRef?: string - forEachThrows?: boolean - revParseThrows?: boolean + defaultRef?: string + remoteRefs?: string + remoteProbeFails?: boolean }): void => { gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { - if (args[0] === 'symbolic-ref') { - return { stdout: opts.symbolicRef ?? '', stderr: '' } + if (args[0] === 'for-each-ref' && args.includes('--format=%(refname)%00%(symref)')) { + return { + stdout: opts.defaultRef ? `refs/remotes/origin/HEAD\0${opts.defaultRef}\n` : '', + stderr: '' + } } if (args[0] === 'remote') { return { stdout: 'origin\n', stderr: '' } } if (args[0] === 'show-ref') { - if (opts.forEachThrows) { + if (opts.remoteProbeFails) { throw new Error('ssh: connect: connection refused') } - const availableRefs = (opts.forEachRef ?? '') + const availableRefs = (opts.remoteRefs ?? '') .split(/\r?\n/) .map((ref) => ref.trim()) .filter(Boolean) @@ -611,15 +615,12 @@ describe('getHostedReviewCreationEligibility', () => { } throw Object.assign(new Error('missing ref'), { code: 1 }) } - if (args[0] === 'rev-parse' && opts.revParseThrows) { - throw new Error('unknown revision') - } return { stdout: 'refs/remotes/origin/main\n', stderr: '' } }) } it('falls back to the repo default when a stacked parent base is local-only', async () => { - mockRefs({ symbolicRef: 'refs/remotes/origin/main\n' }) + mockRefs({ defaultRef: 'refs/remotes/origin/main' }) await expect(getHostedReviewCreationEligibility(stackedArgs())).resolves.toMatchObject({ canCreate: true, blockedReason: null, @@ -628,7 +629,7 @@ describe('getHostedReviewCreationEligibility', () => { }) it('preserves a stacked parent base that exists on the remote', async () => { - mockRefs({ forEachRef: 'refs/remotes/origin/parent-pushed\n' }) + mockRefs({ remoteRefs: 'refs/remotes/origin/parent-pushed\n' }) await expect( getHostedReviewCreationEligibility(stackedArgs({ base: 'parent-pushed' })) ).resolves.toMatchObject({ @@ -639,7 +640,7 @@ describe('getHostedReviewCreationEligibility', () => { }) it('keeps the candidate base when no repo default can be resolved', async () => { - mockRefs({ revParseThrows: true }) + mockRefs({}) await expect(getHostedReviewCreationEligibility(stackedArgs())).resolves.toMatchObject({ canCreate: true, blockedReason: null, @@ -650,7 +651,7 @@ describe('getHostedReviewCreationEligibility', () => { it('preserves the candidate base when the remote probe cannot reach the host', async () => { // Transport failure must not be read as "absent" — that would demote a // legitimately-pushed parent to the repo default on a transient SSH blip. - mockRefs({ forEachThrows: true }) + mockRefs({ remoteProbeFails: true }) await expect( getHostedReviewCreationEligibility(stackedArgs({ base: 'parent-pushed' })) ).resolves.toMatchObject({ canCreate: true, defaultBaseRef: 'parent-pushed' }) diff --git a/src/main/source-control/repo-default-branch.test.ts b/src/main/source-control/repo-default-branch.test.ts index 0c5984ae9bc..a2dd56a8cd0 100644 --- a/src/main/source-control/repo-default-branch.test.ts +++ b/src/main/source-control/repo-default-branch.test.ts @@ -21,11 +21,8 @@ import { function primeLocalGitExec(defaultRef = 'refs/remotes/origin/master'): void { gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { - if (args[0] === 'symbolic-ref' && args.includes('refs/remotes/origin/HEAD')) { - return { stdout: `${defaultRef}\n`, stderr: '' } - } - if (args[0] === 'rev-parse' && args[1] === '--verify' && args.includes(defaultRef)) { - return { stdout: 'default-oid\n', stderr: '' } + if (args[0] === 'for-each-ref' && args.includes('--format=%(refname)%00%(symref)')) { + return { stdout: `refs/remotes/origin/HEAD\0${defaultRef}\n`, stderr: '' } } throw new Error(`unexpected git call: ${args.join(' ')}`) }) @@ -43,7 +40,7 @@ describe('getRepoDefaultBranchName', () => { await expect(getRepoDefaultBranchName('/repo')).resolves.toBe('master') expect(gitExecFileAsyncMock).toHaveBeenCalledWith( - ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], + expect.arrayContaining(['for-each-ref', '--format=%(refname)%00%(symref)']), // Why: the timeout keeps a dead filesystem from wedging the serial PR // refresh drain — assert it stays armed on the local path. { cwd: '/repo', timeout: expect.any(Number) } @@ -57,7 +54,7 @@ describe('getRepoDefaultBranchName', () => { 'main' ) expect(gitExecFileAsyncMock).toHaveBeenCalledWith( - ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], + expect.arrayContaining(['for-each-ref', '--format=%(refname)%00%(symref)']), { cwd: '/repo', wslDistro: 'Ubuntu', timeout: expect.any(Number) } ) }) @@ -66,8 +63,8 @@ describe('getRepoDefaultBranchName', () => { const provider = { exec: vi.fn(async (args: string[], repoPath: string) => { expect(repoPath).toBe('/remote/repo') - if (args[0] === 'symbolic-ref') { - return { stdout: 'refs/remotes/origin/trunk\n' } + if (args[0] === 'for-each-ref') { + return { stdout: 'refs/remotes/origin/HEAD\0refs/remotes/origin/trunk\n' } } return { stdout: 'oid\n' } }) @@ -77,7 +74,7 @@ describe('getRepoDefaultBranchName', () => { await expect(getRepoDefaultBranchName('/remote/repo', 'ssh-1')).resolves.toBe('trunk') expect(getSshGitProviderMock).toHaveBeenCalledWith('ssh-1') expect(provider.exec).toHaveBeenCalledWith( - ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], + expect.arrayContaining(['for-each-ref', '--format=%(refname)%00%(symref)']), '/remote/repo', { timeoutMs: expect.any(Number) } ) @@ -134,17 +131,14 @@ describe('getRepoDefaultBranchName', () => { }) it('coalesces concurrent resolutions for the same repo and runtime', async () => { - let releaseSymbolicRef: (() => void) | undefined - const symbolicRefGate = new Promise((resolve) => { - releaseSymbolicRef = resolve + let releaseSnapshot: (() => void) | undefined + const snapshotGate = new Promise((resolve) => { + releaseSnapshot = resolve }) gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { - if (args[0] === 'symbolic-ref') { - await symbolicRefGate - return { stdout: 'refs/remotes/origin/main\n', stderr: '' } - } - if (args[0] === 'rev-parse') { - return { stdout: 'default-oid\n', stderr: '' } + if (args[0] === 'for-each-ref') { + await snapshotGate + return { stdout: 'refs/remotes/origin/HEAD\0refs/remotes/origin/main\n', stderr: '' } } throw new Error(`unexpected git call: ${args.join(' ')}`) }) @@ -152,10 +146,10 @@ describe('getRepoDefaultBranchName', () => { const first = getRepoDefaultBranchName('/repo') const second = getRepoDefaultBranchName('/repo') await vi.waitFor(() => expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)) - releaseSymbolicRef?.() + releaseSnapshot?.() await expect(Promise.all([first, second])).resolves.toEqual(['main', 'main']) - expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) }) it('scopes the cache per runtime so WSL and host resolutions do not collide', async () => { diff --git a/src/main/text-generation/pull-request-context.ts b/src/main/text-generation/pull-request-context.ts index 2840256075d..74fa4e8c3b7 100644 --- a/src/main/text-generation/pull-request-context.ts +++ b/src/main/text-generation/pull-request-context.ts @@ -204,7 +204,14 @@ export async function getPullRequestDraftContext( const range = `${mergeBase}..HEAD` const [commitSummary, changeSummary, patch] = await Promise.all([ - safeExec(execGit, ['log', '--pretty=format:- %s', '--max-count=50', range]), + safeExec(execGit, [ + 'log', + '--no-show-signature', + '--no-color', + '--pretty=format:- %s', + '--max-count=50', + range + ]), safeExec(execGit, ['diff', '--name-status', range]), safeExec(execGit, ['diff', '--patch', '--minimal', '--no-color', '--no-ext-diff', range]) ]) diff --git a/src/relay/git-buffer-overflow.ts b/src/relay/git-buffer-overflow.ts index 3e86c435dfc..8f9a80cc812 100644 --- a/src/relay/git-buffer-overflow.ts +++ b/src/relay/git-buffer-overflow.ts @@ -1,12 +1,23 @@ export function isGitBufferOverflowError(error: unknown): boolean { - if (!error || typeof error !== 'object') { - return false - } + return ( + !!error && + typeof error === 'object' && + (('code' in error && + (error.code === 'ENOBUFS' || error.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER')) || + ('message' in error && + typeof error.message === 'string' && + /^(?:(?:stdout|stderr) maxBuffer length exceeded|git (?:stdout|stderr|output) exceeded maxBuffer\.)$/.test( + error.message + ))) + ) +} - const maybeError = error as { code?: unknown; message?: unknown } - if (maybeError.code === 'ENOBUFS') { - return true - } - - return typeof maybeError.message === 'string' && /\bmaxBuffer\b/i.test(maybeError.message) +export function isGitReadInterruptedError(error: unknown): boolean { + return ( + !!error && + typeof error === 'object' && + (('name' in error && error.name === 'AbortError') || + ('timedOut' in error && error.timedOut === true) || + ('killed' in error && error.killed === true && !isGitBufferOverflowError(error))) + ) } diff --git a/src/relay/git-command-admission.test.ts b/src/relay/git-command-admission.test.ts new file mode 100644 index 00000000000..96530e004e3 --- /dev/null +++ b/src/relay/git-command-admission.test.ts @@ -0,0 +1,145 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ProcessSpec } from '../shared/child-process/process-spec' +import { GitAdmissionScheduler } from '../shared/git-admission-scheduler' + +const { capture } = vi.hoisted(() => ({ capture: vi.fn() })) +vi.mock('../shared/child-process/run-process', () => ({ runProcess: capture })) + +import { + _resetRelayGitAdmissionForTests, + runGitToTermination +} from './git-handler-command-termination' + +describe('relay Git command ownership', () => { + let scheduler: GitAdmissionScheduler + const success = { code: 0, signal: null, stdout: 'result', stderr: '', timedOut: false } + + beforeEach(() => { + scheduler = new GitAdmissionScheduler({ generalCap: 1, generalHeadroom: 0 }) + _resetRelayGitAdmissionForTests(scheduler) + capture.mockReset() + }) + afterEach(() => _resetRelayGitAdmissionForTests()) + + it('bounds reads while preserving explicit write and network timeout policy', async () => { + capture.mockImplementation(async (spec: ProcessSpec) => { + spec.onChildTerminated?.() + return success + }) + await runGitToTermination( + ['-c', 'core.quotePath=false', 'show', 'HEAD:file'], + { cwd: '/repo' }, + undefined + ) + await runGitToTermination(['fetch', 'origin'], { cwd: '/repo' }, undefined) + await runGitToTermination(['reset', '--quiet'], { cwd: '/repo', timeout: 800 }, undefined) + expect(capture.mock.calls.map(([spec]) => spec.timeoutMs)).toEqual([120_000, null, 800]) + expect(capture.mock.calls[0][0]).toMatchObject({ + terminationBarrier: true, + killOnOutputLimit: true + }) + }) + + it('cancels a queued read without spawning or releasing an active child', async () => { + let finish!: () => void + capture.mockImplementationOnce( + (spec: ProcessSpec) => + new Promise((resolve) => { + finish = () => { + spec.onChildTerminated?.() + resolve(success) + } + }) + ) + const active = runGitToTermination(['show', 'HEAD:file'], { cwd: '/repo' }, undefined) + await vi.waitFor(() => expect(capture).toHaveBeenCalledTimes(1)) + const controller = new AbortController() + const queued = runGitToTermination( + ['show', 'HEAD:other'], + { cwd: '/repo', signal: controller.signal }, + undefined + ) + const rejection = expect(queued).rejects.toMatchObject({ name: 'AbortError' }) + controller.abort() + await rejection + expect(capture).toHaveBeenCalledTimes(1) + expect(scheduler.snapshot()).toMatchObject({ queued: 0, budgets: { general: { baseUsed: 1 } } }) + finish() + await active + expect(scheduler.snapshot().budgets.general.baseUsed).toBe(0) + }) + + it('holds admission after a capture rejects until child termination is reported', async () => { + let reportTermination: (() => void) | undefined + capture.mockImplementationOnce(async (spec: ProcessSpec) => { + reportTermination = spec.onChildTerminated + throw new Error('capture failed before close') + }) + await expect(runGitToTermination(['status'], { cwd: '/repo' }, undefined)).rejects.toThrow( + 'before close' + ) + expect(scheduler.snapshot().budgets.general.baseUsed).toBe(1) + reportTermination?.() + expect(scheduler.snapshot().budgets.general.baseUsed).toBe(0) + }) + + it('rejects truncated zero-exit output instead of parsing an incomplete result', async () => { + capture.mockImplementationOnce(async (spec: ProcessSpec) => { + spec.onChildTerminated?.() + return { ...success, outputTruncated: true } + }) + await expect(runGitToTermination(['log'], { cwd: '/repo' }, undefined)).rejects.toMatchObject({ + code: 'ENOBUFS' + }) + }) + + it('allows truncated diagnostic tails without terminating a successful clone', async () => { + capture.mockImplementationOnce(async (spec: ProcessSpec, mode: string) => { + expect(mode).toBe('tail') + expect(spec).toMatchObject({ maxOutputBytes: 4096, killOnOutputLimit: false }) + spec.onChildTerminated?.() + return { ...success, outputTruncated: true } + }) + await expect( + runGitToTermination( + ['clone', '--progress', 'source', 'target'], + { cwd: '/repo', maxBuffer: 4096, outputCapture: 'tail' }, + undefined + ) + ).resolves.toEqual({ stdout: 'result', stderr: '' }) + expect(scheduler.snapshot().budgets.network.baseUsed).toBe(0) + }) + + it.each([ + { code: 128, timedOut: false, signal: null, message: 'fatal: repository unavailable' }, + { code: null, timedOut: true, signal: 'SIGTERM', message: 'git clone timed out.' } + ])('preserves a noisy clone failure or deadline: $message', async (failure) => { + capture.mockImplementationOnce(async (spec: ProcessSpec) => { + spec.onChildTerminated?.() + return { + ...success, + ...failure, + stderr: 'fatal: repository unavailable', + outputTruncated: true + } + }) + await expect( + runGitToTermination(['clone'], { cwd: '/repo', outputCapture: 'tail' }, undefined) + ).rejects.toMatchObject({ code: failure.code, message: failure.message }) + }) + + it('returns captured bytes without round-tripping through UTF-8', async () => { + const bytes = Buffer.from([0, 255, 254, 128, 65]) + capture.mockImplementationOnce(async (spec: ProcessSpec) => { + expect(spec.captureStdoutAsBytes).toBe(true) + spec.onChildTerminated?.() + return { ...success, stdout: '', stdoutBytes: bytes } + }) + const result = await runGitToTermination( + ['show', 'HEAD:file'], + { cwd: '/repo', captureStdoutAsBytes: true }, + undefined + ) + expect(result.stdoutBytes).toEqual(bytes) + }) +}) diff --git a/src/relay/git-diff-cancellation.test.ts b/src/relay/git-diff-cancellation.test.ts new file mode 100644 index 00000000000..f6dea13ec2a --- /dev/null +++ b/src/relay/git-diff-cancellation.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from 'vitest' +import { createGitHandlerRelay } from './git-handler-test-harness' +import type { GitHandlerOperationHost } from './git-handler-operation-context' + +describe('relay diff request cancellation', () => { + it('lets one client cancel without stopping another client sharing the same read', async () => { + const { handler, dispatcher } = createGitHandlerRelay() + let finish!: () => void + const ready = new Promise((resolve) => { + finish = () => resolve(Buffer.from('content\n')) + }) + const signals: AbortSignal[] = [] + const gitBuffer = vi.fn(async (_args, _cwd, options) => { + if (options?.signal) { + signals.push(options.signal) + } + return ready + }) + Object.assign(handler, { gitBuffer, git: async () => ({ stdout: '', stderr: '' }) }) + const params = { worktreePath: '/repo', filePath: 'file.txt', staged: true } + const first = new AbortController() + const second = new AbortController() + const canceled = dispatcher.callRequest('git.diff', params, { + isStale: () => false, + signal: first.signal + }) + const remaining = dispatcher.callRequest('git.diff', params, { + isStale: () => false, + signal: second.signal + }) + await vi.waitFor(() => expect(gitBuffer).toHaveBeenCalledTimes(2)) + const rejected = expect(canceled).rejects.toMatchObject({ name: 'AbortError' }) + first.abort() + await rejected + expect(signals.every((signal) => !signal.aborted)).toBe(true) + finish() + await expect(remaining).resolves.toMatchObject({ + originalContent: 'content\n', + modifiedContent: 'content\n' + }) + handler.dispose() + }) + + it('stops the shared subprocess reads when their last client cancels', async () => { + const { handler, dispatcher } = createGitHandlerRelay() + const signals: AbortSignal[] = [] + const gitBuffer = vi.fn(async (_args, _cwd, options) => { + const signal = options?.signal + if (!signal) { + throw new Error('Request cancellation was not passed to the blob read.') + } + signals.push(signal) + return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }) + }) + }) + Object.assign(handler, { gitBuffer, git: async () => ({ stdout: '', stderr: '' }) }) + const controller = new AbortController() + const pending = dispatcher.callRequest( + 'git.diff', + { worktreePath: '/repo', filePath: 'file.txt', staged: true }, + { + isStale: () => false, + signal: controller.signal + } + ) + await vi.waitFor(() => expect(gitBuffer).toHaveBeenCalledTimes(2)) + const rejected = expect(pending).rejects.toMatchObject({ name: 'AbortError' }) + controller.abort() + await rejected + expect(signals.every((signal) => signal.aborted)).toBe(true) + handler.dispose() + }) +}) diff --git a/src/relay/git-handler-blob-readers.test.ts b/src/relay/git-handler-blob-readers.test.ts index b2b9e87ecbb..960106b21a2 100644 --- a/src/relay/git-handler-blob-readers.test.ts +++ b/src/relay/git-handler-blob-readers.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it, vi } from 'vitest' -import { readBlobAtIndex, readBlobAtOid, type GitBufferExec } from './git-handler-ops' +import { + readBlobAtIndex, + readBlobAtOid, + readUnstagedLeft, + type GitBufferExec +} from './git-handler-ops' describe('git blob readers', () => { it('normalizes Windows separators before reading OID blobs', async () => { @@ -15,11 +20,13 @@ describe('git blob readers', () => { }) it('marks OID blobs that overflow maxBuffer as binary', async () => { - const gitBuffer = vi - .fn() - .mockRejectedValue( - Object.assign(new Error('stdout maxBuffer length exceeded'), { code: 'ENOBUFS' }) - ) + const gitBuffer = vi.fn().mockRejectedValue( + Object.assign(new Error('stdout maxBuffer length exceeded'), { + code: 'ENOBUFS', + killed: true, + signal: 'SIGTERM' + }) + ) const result = await readBlobAtOid(gitBuffer, '/repo', 'HEAD', 'large.log') @@ -36,15 +43,29 @@ describe('git blob readers', () => { }) it('marks index blobs that overflow maxBuffer as binary', async () => { - const gitBuffer = vi - .fn() - .mockRejectedValue( - Object.assign(new Error('git stdout exceeded maxBuffer.'), { code: 'ENOBUFS' }) - ) + const gitBuffer = vi.fn().mockRejectedValue( + Object.assign(new Error('git stdout exceeded maxBuffer.'), { + code: 'ENOBUFS', + killed: true, + signal: 'SIGTERM' + }) + ) const result = await readBlobAtIndex(gitBuffer, '/repo', 'large.log') // Why: overflow is size-capped content, not a staged deletion (missing: false). expect(result).toEqual({ content: '', isBinary: true, missing: false }) }) + + it('does not spawn a HEAD read for a successfully read empty index blob', async () => { + const gitBuffer = vi.fn().mockResolvedValue(Buffer.alloc(0)) + + const result = await readUnstagedLeft(gitBuffer, '/repo', 'empty.txt') + + expect(result.content).toBe('') + expect(gitBuffer).toHaveBeenCalledExactlyOnceWith( + ['show', '--end-of-options', ':empty.txt'], + '/repo' + ) + }) }) diff --git a/src/relay/git-handler-branch-cleanup.ts b/src/relay/git-handler-branch-cleanup.ts index 3bde9cfcdaf..c2bfcae599f 100644 --- a/src/relay/git-handler-branch-cleanup.ts +++ b/src/relay/git-handler-branch-cleanup.ts @@ -4,6 +4,8 @@ import { } from '../shared/git-branch-cleanup' import type { GitCapabilityCache } from '../shared/git-capability-cache' import type { GitExec } from './git-handler-ops' +import { expandTilde } from './context' +import { isBranchInDetachedWorktree } from '../shared/git-worktree-admin' import { parseWorktreeList } from '../shared/git-worktree-porcelain-parser' export async function deleteAlreadyMergedRelayBranchAfterSafeDeleteFailure( @@ -69,7 +71,11 @@ async function deleteRelayBranchAtExpectedHead( // and removeWorktree cleanup still rely on their distinct/raw failures. throw mapUpdateRefError?.(error) ?? error } - if (await isRelayBranchCheckedOut(git, repoPath, branchName)) { + try { + if (await isRelayBranchCheckedOut(git, repoPath, branchName)) { + throw new Error(`Local branch "${branchName}" is checked out in another worktree.`) + } + } catch (error) { try { await git(['update-ref', `refs/heads/${branchName}`, expectedHead, ''], repoPath) } catch (restoreError) { @@ -78,7 +84,7 @@ async function deleteRelayBranchAtExpectedHead( restoreError ) } - throw new Error(`Local branch "${branchName}" is checked out in another worktree.`) + throw error } try { await git(['config', '--remove-section', `branch.${branchName}`], repoPath) @@ -94,9 +100,12 @@ async function isRelayBranchCheckedOut( branchName: string ): Promise { const { stdout } = await git(['worktree', 'list', '--porcelain'], repoPath) - return parseWorktreeList(stdout).some( - (worktree) => - typeof worktree.branch === 'string' && - worktree.branch.replace(/^refs\/heads\//, '') === branchName + const worktrees = parseWorktreeList(stdout) + return ( + worktrees.some( + (worktree) => + typeof worktree.branch === 'string' && + worktree.branch.replace(/^refs\/heads\//, '') === branchName + ) || isBranchInDetachedWorktree(expandTilde(repoPath), branchName, worktrees) ) } diff --git a/src/relay/git-handler-branch-diff-ops.ts b/src/relay/git-handler-branch-diff-ops.ts index 1da0de82be8..6a85c7f6630 100644 --- a/src/relay/git-handler-branch-diff-ops.ts +++ b/src/relay/git-handler-branch-diff-ops.ts @@ -1,3 +1,4 @@ +import { isGitReadInterruptedError } from './git-buffer-overflow' import { buildDiffResult } from './git-diff-result' import { readBlobAtOid, type GitBufferExec } from './git-handler-ops' @@ -43,7 +44,10 @@ export async function branchDiffEntryAtPinnedOids( readBlobAtOid(gitBuffer, worktreePath, headOid, filePath) ]) return [buildDiffResult(left.content, right.content, left.isBinary, right.isBinary, filePath)] - } catch { + } catch (error) { + if (isGitReadInterruptedError(error)) { + throw error + } return [ { kind: 'text' as const, diff --git a/src/relay/git-handler-clone-progress-capture.test.ts b/src/relay/git-handler-clone-progress-capture.test.ts new file mode 100644 index 00000000000..2c560d6d3d0 --- /dev/null +++ b/src/relay/git-handler-clone-progress-capture.test.ts @@ -0,0 +1,150 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ChildProcess } from 'node:child_process' +import type * as ChildProcessModule from 'node:child_process' +import { createFakeSpawnedChild } from '../shared/child-process/__fixtures__/fake-spawned-child' +import { GitAdmissionScheduler } from '../shared/git-admission-scheduler' +import { createGitHandlerRelay } from './git-handler-test-harness' +import { _resetRelayGitAdmissionForTests } from './git-handler-command-termination' + +const { spawn, signalTree, forceTree } = vi.hoisted(() => ({ + spawn: vi.fn<(program: string, args: readonly string[]) => ChildProcess>(), + signalTree: vi.fn<(child: ChildProcess, signal?: NodeJS.Signals) => Promise>(), + forceTree: vi.fn<(child: ChildProcess) => Promise>() +})) +vi.mock('node:child_process', async (importOriginal) => ({ + ...(await importOriginal()), + spawn +})) +vi.mock('../shared/child-process/process-tree-termination', () => ({ + signalProcessTree: signalTree, + forceTerminateProcessTree: forceTree +})) + +const request = { + args: ['clone', '--progress', '--', 'https://example.com/repository.git', 'destination'], + cwd: process.cwd(), + progressId: 'clone-progress' +} +const noise = Buffer.from(`Receiving objects: 42%\r${'x'.repeat(65_512)}\r`) + +function emitNoise(child: ChildProcess): void { + for (let index = 0; index < 193; index++) { + child.stderr?.emit('data', noise) + } +} + +describe('relay clone progress capture', () => { + let relay: ReturnType + let scheduler: GitAdmissionScheduler + let child: ChildProcess + + beforeEach(() => { + vi.useFakeTimers() + vi.stubEnv('GIT_SSH_COMMAND', 'ssh') + scheduler = new GitAdmissionScheduler({ networkCap: 1, networkHeadroom: 0 }) + _resetRelayGitAdmissionForTests(scheduler) + child = createFakeSpawnedChild() + spawn.mockReset().mockReturnValue(child) + signalTree.mockReset().mockResolvedValue(false) + forceTree.mockReset().mockResolvedValue(false) + relay = createGitHandlerRelay() + }) + + afterEach(() => { + relay.handler.dispose() + _resetRelayGitAdmissionForTests() + vi.unstubAllEnvs() + vi.useRealTimers() + }) + + it('completes after more than 10 MiB of progress with bounded final output', async () => { + const pending = relay.dispatcher.callRequest('git.clone', request) + await vi.advanceTimersByTimeAsync(0) + expect(spawn).toHaveBeenCalledOnce() + expect(scheduler.snapshot().budgets.network.baseUsed).toBe(1) + expect(noise.length * 193).toBeGreaterThan(10 * 1024 * 1024) + emitNoise(child) + const finalStderr = 'Receiving objects: 100%\rCLONE_FINAL_TAIL\n' + child.stderr?.emit('data', Buffer.from(finalStderr)) + child.stdout?.emit('data', Buffer.from(`${'o'.repeat(8192)}STDOUT_FINAL\n`)) + expect(signalTree).not.toHaveBeenCalled() + expect(forceTree).not.toHaveBeenCalled() + child.emit('exit', 0, null) + child.emit('close', 0, null) + + await expect(pending).resolves.toEqual({ + stdout: `${'o'.repeat(8192)}STDOUT_FINAL\n`.slice(-4096), + stderr: `${noise.toString()}${finalStderr}`.slice(-4096) + }) + expect(relay.dispatcher.notify).toHaveBeenCalledTimes(194) + expect(relay.dispatcher.notify).toHaveBeenLastCalledWith('git.cloneProgress', { + progressId: request.progressId, + phase: 'Receiving objects', + percent: 100 + }) + expect(scheduler.snapshot().budgets.network.baseUsed).toBe(0) + expect(vi.getTimerCount()).toBe(0) + }) + + it('reports the final fatal diagnostic after noisy progress and exit 128', async () => { + const pending = relay.dispatcher.callRequest('git.clone', request) + const rejection = expect(pending).rejects.toThrow('Clone failed: fatal: repository unavailable') + await vi.advanceTimersByTimeAsync(0) + expect(spawn).toHaveBeenCalledOnce() + emitNoise(child) + child.stderr?.emit('data', Buffer.from('fatal: repository unavailable\n')) + child.emit('exit', 128, null) + child.emit('close', 128, null) + await rejection + expect(signalTree).not.toHaveBeenCalled() + expect(forceTree).not.toHaveBeenCalled() + expect(scheduler.snapshot().budgets.network.baseUsed).toBe(0) + expect(vi.getTimerCount()).toBe(0) + }) + + it('holds admission on cancellation until the clone tree is confirmed terminated', async () => { + let confirmTermination: (terminated: boolean) => void = () => { + throw new Error('Termination verification has not started') + } + forceTree.mockImplementation( + () => + new Promise((resolve) => { + confirmTermination = resolve + }) + ) + const controller = new AbortController() + const pending = relay.dispatcher.callRequest('git.clone', request, { + isStale: () => false, + signal: controller.signal + }) + const rejection = expect(pending).rejects.toMatchObject({ name: 'AbortError' }) + let settled = false + void pending.then( + () => { + settled = true + }, + () => { + settled = true + } + ) + await vi.advanceTimersByTimeAsync(0) + expect(spawn).toHaveBeenCalledOnce() + emitNoise(child) + expect(signalTree).not.toHaveBeenCalled() + controller.abort() + await vi.advanceTimersByTimeAsync(2000) + expect(signalTree).toHaveBeenCalledWith(child, undefined) + expect(forceTree).toHaveBeenCalledWith(child) + expect(settled).toBe(false) + expect(scheduler.snapshot().budgets.network.baseUsed).toBe(1) + + confirmTermination(true) + await vi.advanceTimersByTimeAsync(0) + await rejection + expect(scheduler.snapshot().budgets.network.baseUsed).toBe(0) + child.emit('exit', null, 'SIGKILL') + child.emit('close', null, 'SIGKILL') + expect(scheduler.snapshot().budgets.network.baseUsed).toBe(0) + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/src/relay/git-handler-command-termination.ts b/src/relay/git-handler-command-termination.ts index 0cb6bc08995..f028292b03d 100644 --- a/src/relay/git-handler-command-termination.ts +++ b/src/relay/git-handler-command-termination.ts @@ -1,7 +1,21 @@ +import { + signalProcessTree, + forceTerminateProcessTree +} from '../shared/child-process/process-tree-termination' +import type { ProcessTerminationBarrier } from '../shared/child-process/process-spec' import { runProcess } from '../shared/child-process/run-process' +import { GitAdmissionScheduler } from '../shared/git-admission-scheduler' +import type { GitAdmissionRequest } from '../shared/git-admission-state' +import { gitCommandTimeoutMs } from '../shared/git-command-timeout' export const MAX_GIT_BUFFER = 10 * 1024 * 1024 -const GIT_REBASE_PROCESS_FALLBACK_TIMEOUT_MS = 2_147_000_000 +let scheduler = new GitAdmissionScheduler() + +export function _resetRelayGitAdmissionForTests(replacement = new GitAdmissionScheduler()): void { + scheduler = replacement +} + +export const acquireRelayGitAdmission = (request: GitAdmissionRequest) => scheduler.acquire(request) type GitTerminationOptions = { cwd?: string @@ -9,45 +23,77 @@ type GitTerminationOptions = { timeout?: number maxBuffer?: number signal?: AbortSignal + captureStdoutAsBytes?: boolean + outputCapture?: 'tail' + observeStderr?: ProcessTerminationBarrier['observeStderr'] } export async function runGitToTermination( args: string[], options: GitTerminationOptions, stdin: string | undefined -): Promise<{ stdout: string; stderr: string }> { - const result = await runProcess({ - program: 'git', +): Promise<{ stdout: string; stderr: string; stdoutBytes?: Buffer }> { + const grant = await acquireRelayGitAdmission({ args, - cwd: typeof options.cwd === 'string' ? options.cwd : undefined, - env: options.env, - timeoutMs: - typeof options.timeout === 'number' - ? options.timeout - : GIT_REBASE_PROCESS_FALLBACK_TIMEOUT_MS, - maxOutputBytes: typeof options.maxBuffer === 'number' ? options.maxBuffer : MAX_GIT_BUFFER, - signal: options.signal, - terminationBarrier: true, - ...(stdin === undefined ? {} : { input: stdin }) + cwd: options.cwd ?? '.', + signal: options.signal }) - if (result.code === 0 && !result.timedOut && !options.signal?.aborted) { - return { stdout: result.stdout, stderr: result.stderr } + // A rejected capture can precede child termination; the child owns the grant. + const result = await runProcess( + { + program: 'git', + args, + cwd: options.cwd, + env: options.env, + timeoutMs: gitCommandTimeoutMs(args, options.timeout) ?? null, + maxOutputBytes: options.maxBuffer ?? MAX_GIT_BUFFER, + captureStdoutAsBytes: options.captureStdoutAsBytes, + killOnOutputLimit: options.outputCapture !== 'tail', + signal: options.signal, + terminationBarrier: options.observeStderr + ? { + observeStderr: options.observeStderr, + signal: signalProcessTree, + force: forceTerminateProcessTree + } + : true, + onChildTerminated: grant.release, + ...(stdin === undefined ? {} : { input: stdin }) + }, + options.outputCapture + ) + const outputExceeded = result.outputTruncated === true && options.outputCapture !== 'tail' + if ( + result.code === 0 && + !result.signal && + !result.timedOut && + !options.signal?.aborted && + !outputExceeded + ) { + return { + stdout: result.stdout, + stderr: result.stderr, + ...(result.stdoutBytes ? { stdoutBytes: result.stdoutBytes } : {}) + } } const error = new Error( - result.timedOut - ? `git ${args[0] ?? 'command'} timed out.` - : options.signal?.aborted - ? 'The operation was aborted.' - : result.stderr.trim() || `git ${args[0] ?? 'command'} failed.` + outputExceeded + ? 'git output exceeded maxBuffer.' + : result.timedOut + ? `git ${args[0] ?? 'command'} timed out.` + : options.signal?.aborted + ? 'The operation was aborted.' + : result.stderr.trim() || `git ${args[0] ?? 'command'} failed.` ) if (options.signal?.aborted) { error.name = 'AbortError' } throw Object.assign(error, { - code: result.code, + code: outputExceeded ? 'ENOBUFS' : result.code, + timedOut: result.timedOut, killed: result.timedOut || result.signal !== null || options.signal?.aborted === true, signal: result.signal, - stdout: result.stdout, + stdout: result.stdoutBytes ?? result.stdout, stderr: result.stderr }) } diff --git a/src/relay/git-handler-commit-diff-ops.ts b/src/relay/git-handler-commit-diff-ops.ts index b4fe9fd79d2..6e5eda44cbe 100644 --- a/src/relay/git-handler-commit-diff-ops.ts +++ b/src/relay/git-handler-commit-diff-ops.ts @@ -1,8 +1,8 @@ +import { isGitReadInterruptedError } from './git-buffer-overflow' import { readBlobAtOid, type GitBufferExec, type GitExec } from './git-handler-ops' -import { parseBranchDiff } from './git-handler-utils' +import { gitChangeListArgs, parseGitChangeList } from '../shared/git-change-list' import { buildDiffResult } from './git-diff-result' -import { parseNumstat } from '../shared/git-uncommitted-line-stats' -import { parseGitRevListFirstParentOid } from '../shared/git-rev-list-output' +import { parseGitRevListCommitAndFirstParentOid } from '../shared/git-rev-list-output' const FULL_GIT_OBJECT_ID_PATTERN = /^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/ @@ -15,25 +15,40 @@ function assertFullGitObjectId(value: string, label: string): void { export async function commitCompare(git: GitExec, worktreePath: string, commitId: string) { assertFullGitObjectId(commitId, 'commitId') let commitOid = '' + let parentOid: string | null = null + let parentReadFailure: { error: unknown } | undefined try { const { stdout } = await git( - ['rev-parse', '--verify', '--end-of-options', `${commitId}^{commit}`], + ['rev-list', '--parents', '-n', '1', '--end-of-options', `${commitId}^{commit}`], worktreePath ) - commitOid = stdout.trim() - } 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: [] + ;({ commitOid, parentOid } = parseGitRevListCommitAndFirstParentOid(stdout)) + } catch (error) { + // Why: preserve invalid-commit versus a resolved commit with unreadable parents on failure. + try { + const { stdout } = await git( + ['rev-parse', '--verify', '--end-of-options', `${commitId}^{commit}`], + worktreePath + ) + commitOid = stdout.trim() + } catch (error) { + if (isGitReadInterruptedError(error)) { + throw error + } + 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: [] + } } + parentReadFailure = { error } } const summary = { @@ -46,67 +61,14 @@ export async function commitCompare(git: GitExec, worktreePath: string, commitId } try { - const { stdout: parentsOut } = await git( - ['rev-list', '--parents', '-n', '1', commitOid], - worktreePath - ) - const firstParent = parseGitRevListFirstParentOid(parentsOut) - summary.parentOid = firstParent - summary.baseRef = firstParent ? firstParent.slice(0, 7) : 'empty tree' + if (parentReadFailure) { + throw parentReadFailure.error + } + summary.parentOid = parentOid + summary.baseRef = parentOid ? parentOid.slice(0, 7) : 'empty tree' - // Why: root commits have no parent tree; diff-tree --root asks git to - // compare against the repository's empty tree without hardcoding hash format. - const diffArgs = summary.parentOid - ? [ - '-c', - 'core.quotePath=false', - 'diff', - '--name-status', - '-M', - '-C', - summary.parentOid, - commitOid - ] - : [ - '-c', - 'core.quotePath=false', - 'diff-tree', - '--root', - '--no-commit-id', - '--name-status', - '-r', - '-M', - '-C', - commitOid - ] - const numstatArgs = summary.parentOid - ? [ - '-c', - 'core.quotePath=false', - 'diff', - '--numstat', - '-M', - '-C', - summary.parentOid, - commitOid - ] - : [ - '-c', - 'core.quotePath=false', - 'diff-tree', - '--root', - '--no-commit-id', - '--numstat', - '-r', - '-M', - '-C', - commitOid - ] - const [{ stdout }, { stdout: numstat }] = await Promise.all([ - git(diffArgs, worktreePath), - git(numstatArgs, worktreePath) - ]) - const entries = parseBranchDiff(stdout, parseNumstat(numstat)) + const { stdout } = await git(gitChangeListArgs(summary.parentOid, commitOid), worktreePath) + const entries = parseGitChangeList(stdout) summary.changedFiles = entries.length return { summary, entries } } catch (error) { @@ -137,10 +99,12 @@ export async function commitDiffEntry( } try { const oldPath = args.oldPath ?? args.filePath - const left = args.parentOid - ? await readBlobAtOid(gitBuffer, worktreePath, args.parentOid, oldPath) - : { content: '', isBinary: false } - const right = await readBlobAtOid(gitBuffer, worktreePath, args.commitOid, args.filePath) + const [left, right] = await Promise.all([ + args.parentOid + ? readBlobAtOid(gitBuffer, worktreePath, args.parentOid, oldPath) + : Promise.resolve({ content: '', isBinary: false }), + readBlobAtOid(gitBuffer, worktreePath, args.commitOid, args.filePath) + ]) return buildDiffResult( left.content, right.content, @@ -148,7 +112,10 @@ export async function commitDiffEntry( right.isBinary, args.filePath ) - } catch { + } catch (error) { + if (isGitReadInterruptedError(error)) { + throw error + } return { kind: 'text', originalContent: '', diff --git a/src/relay/git-handler-commit-metadata.test.ts b/src/relay/git-handler-commit-metadata.test.ts new file mode 100644 index 00000000000..3c94ce92bc3 --- /dev/null +++ b/src/relay/git-handler-commit-metadata.test.ts @@ -0,0 +1,101 @@ +import { unlink, writeFile } from 'node:fs/promises' +import * as path from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { runProcess } from '../shared/child-process/run-process' +import { commitCompare } from './git-handler-commit-diff-ops' +import type { GitExec } from './git-handler-ops' +import { gitCommit, gitInit } from './git-handler-test-setup' +import { createGitTempDir, removeGitTempDir } from './git-handler-test-harness' + +describe('relay commit metadata resolution', () => { + let repo: string + let git: ReturnType> + + beforeEach(async () => { + repo = createGitTempDir() + gitInit(repo) + await writeFile(path.join(repo, 'file.txt'), 'root\n') + gitCommit(repo, 'root') + git = vi.fn(async (args, cwd) => { + const result = await runProcess({ program: 'git', args, cwd }) + if (result.code !== 0) { + throw new Error(result.stderr) + } + return { stdout: result.stdout, stderr: result.stderr } + }) + }) + + afterEach(async () => { + await removeGitTempDir(repo) + }) + + async function oid(ref: string): Promise { + return (await git(['rev-parse', ref], repo)).stdout.trim() + } + + async function nextCommit(): Promise<{ parent: string; commit: string }> { + const parent = await oid('HEAD') + await writeFile(path.join(repo, 'file.txt'), 'child\n') + gitCommit(repo, 'child') + return { parent, commit: await oid('HEAD') } + } + + it('resolves a root commit and loads its changes in two Git calls', async () => { + const commit = await oid('HEAD') + git.mockClear() + + const result = await commitCompare(git, repo, commit) + + expect(result.summary).toMatchObject({ status: 'ready', commitOid: commit, parentOid: null }) + expect(result.entries).toEqual([{ path: 'file.txt', status: 'added', added: 1, removed: 0 }]) + expect(git).toHaveBeenCalledTimes(2) + }) + + it('peels an annotated tag object id while retaining its first commit parent', async () => { + const { commit, parent } = await nextCommit() + await git(['tag', '-a', 'test-tag', '-m', 'annotated'], repo) + const tag = await oid('test-tag') + expect(tag).not.toBe(commit) + git.mockClear() + + const result = await commitCompare(git, repo, tag) + + expect(result.summary).toMatchObject({ status: 'ready', commitOid: commit, parentOid: parent }) + expect(git).toHaveBeenCalledTimes(2) + }) + + it('honors the shallow boundary rather than exposing an unavailable raw parent', async () => { + const { commit } = await nextCommit() + await writeFile(path.join(repo, '.git', 'shallow'), `${commit}\n`) + + const result = await commitCompare(git, repo, commit) + + expect(result.summary).toMatchObject({ status: 'ready', commitOid: commit, parentOid: null }) + }) + + it('preserves the resolved commit when reading a missing parent fails', async () => { + const { commit, parent } = await nextCommit() + await unlink(path.join(repo, '.git', 'objects', parent.slice(0, 2), parent.slice(2))) + git.mockClear() + + const result = await commitCompare(git, repo, commit) + + expect(result.summary).toMatchObject({ status: 'error', commitOid: commit }) + expect(result.entries).toEqual([]) + expect(git.mock.calls.map(([args]) => args[0])).toEqual(['rev-list', 'rev-parse']) + }) + + it('distinguishes a blob object from a valid commit', async () => { + const blob = await oid('HEAD:file.txt') + + const result = await commitCompare(git, repo, blob) + + expect(result.summary).toMatchObject({ status: 'invalid-commit', commitOid: '' }) + }) + + it('rejects arbitrary revision expressions before executing Git', async () => { + git.mockClear() + await expect(commitCompare(git, repo, 'HEAD~1..HEAD')).rejects.toThrow('full git object id') + expect(git).not.toHaveBeenCalled() + }) +}) diff --git a/src/relay/git-handler-comparison-operations.ts b/src/relay/git-handler-comparison-operations.ts index f4c1e55fa5e..72428a33d85 100644 --- a/src/relay/git-handler-comparison-operations.ts +++ b/src/relay/git-handler-comparison-operations.ts @@ -1,81 +1,95 @@ +import type { RequestContext } from './dispatcher' import { GitHandlerOperationContext } from './git-handler-operation-context' import { branchCompare as branchCompareOp } from './git-handler-ops' import { commitCompare as commitCompareOp } from './git-handler-commit-diff-ops' -import { parseBranchDiff } from './git-handler-utils' -import { parseNumstat } from '../shared/git-uncommitted-line-stats' +import { gitChangeListArgs, parseGitChangeList } from '../shared/git-change-list' import { isNoUpstreamError, normalizeGitErrorMessage } from '../shared/git-remote-error' import { upstreamOnlyCommitsArePatchEquivalent } from '../shared/git-upstream-status' import { assertValidGitPushTarget } from '../shared/git-push-target-validation' -import { getPublishTargetStatus, type GitCommandRunner } from '../shared/git-publish-target-status' -import type { GitPushTarget } from '../shared/worktree/types' +import { getPublishTargetStatus } from '../shared/git-publish-target-status' import { getEffectiveGitUpstreamStatus } from '../shared/git-effective-upstream' export class GitHandlerComparisonOperations extends GitHandlerOperationContext { - async branchCompare(params: Record) { + async branchCompare(params: Record, context?: RequestContext) { const worktreePath = params.worktreePath as string const baseRef = params.baseRef as string // Why: reject flag-like base refs to prevent rev-parse option injection. if (baseRef.startsWith('-')) { throw new Error('Base ref must not start with "-"') } - const gitBound = this.git.bind(this) - return branchCompareOp(gitBound, worktreePath, baseRef, async (mergeBase, headOid) => { - // Why: preserve non-ASCII filenames as UTF-8 for parseBranchDiff. - const [{ stdout }, { stdout: numstat }] = await Promise.all([ - gitBound( - ['-c', 'core.quotePath=false', 'diff', '--name-status', '-M', '-C', mergeBase, headOid], - worktreePath - ), - gitBound( - ['-c', 'core.quotePath=false', 'diff', '--numstat', '-M', '-C', mergeBase, headOid], - worktreePath - ) - ]) - return parseBranchDiff(stdout, parseNumstat(numstat)) - }) + const gitBound = this.gitForSignal(context?.signal) + const result = await branchCompareOp( + gitBound, + worktreePath, + baseRef, + async (mergeBase, headOid) => { + const { stdout } = await gitBound(gitChangeListArgs(mergeBase, headOid), worktreePath) + return parseGitChangeList(stdout) + } + ) + context?.signal?.throwIfAborted() + return result } - async commitCompare(params: Record) { + async commitCompare(params: Record, context?: RequestContext) { const worktreePath = params.worktreePath as string const commitId = params.commitId as string - return commitCompareOp(this.git.bind(this), worktreePath, commitId) + const result = await commitCompareOp(this.gitForSignal(context?.signal), worktreePath, commitId) + context?.signal?.throwIfAborted() + return result } - async upstreamStatus(params: Record) { + async upstreamStatus(params: Record, context?: RequestContext) { const worktreePath = params.worktreePath as string + const git = this.gitForSignal(context?.signal) try { if (params.pushTarget !== undefined) { assertValidGitPushTarget(params.pushTarget) - const pushTarget = params.pushTarget as GitPushTarget - await this.git(['check-ref-format', '--branch', pushTarget.branchName], worktreePath) + const pushTarget = params.pushTarget + await git(['check-ref-format', '--branch', pushTarget.branchName], worktreePath) return await getPublishTargetStatus( - ((args) => this.git(args, worktreePath)) as GitCommandRunner, + (args) => git(args, worktreePath), pushTarget, - (upstreamName) => this.getBehindCommitsArePatchEquivalent(worktreePath, upstreamName) + (upstreamName) => + this.getBehindCommitsArePatchEquivalent(worktreePath, upstreamName, context?.signal) ) } return await getEffectiveGitUpstreamStatus( - (args) => this.git(args, worktreePath), - (upstreamName) => this.getBehindCommitsArePatchEquivalent(worktreePath, upstreamName) + (args) => git(args, worktreePath), + (upstreamName) => + this.getBehindCommitsArePatchEquivalent(worktreePath, upstreamName, context?.signal) ) } catch (error) { + context?.signal?.throwIfAborted() // Why: suppress only the expected no-upstream error; surface all others. if (isNoUpstreamError(error)) { return { hasUpstream: false, ahead: 0, behind: 0 } } // Why: match fetch/push/pull normalization so execFile preamble and local paths don't leak to the renderer. throw new Error(normalizeGitErrorMessage(error, 'upstream')) + } finally { + context?.signal?.throwIfAborted() } } private async getBehindCommitsArePatchEquivalent( worktreePath: string, - upstreamName: string + upstreamName: string, + signal?: AbortSignal ): Promise { try { - const { stdout } = await this.git( - ['log', '--oneline', '--cherry-mark', '--right-only', `HEAD...${upstreamName}`, '--'], + const { stdout } = await this.gitForSignal(signal)( + [ + 'log', + '--no-show-signature', + '--no-color', + '--oneline', + '--cherry-mark', + '--right-only', + `HEAD...${upstreamName}`, + '--' + ], worktreePath ) return upstreamOnlyCommitsArePatchEquivalent(stdout) diff --git a/src/relay/git-handler-diff-blobs.test.ts b/src/relay/git-handler-diff-blobs.test.ts new file mode 100644 index 00000000000..88d1d167f2e --- /dev/null +++ b/src/relay/git-handler-diff-blobs.test.ts @@ -0,0 +1,152 @@ +import { writeFile } from 'node:fs/promises' +import * as path from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { runProcess } from '../shared/child-process/run-process' +import { commitDiffEntry } from './git-handler-commit-diff-ops' +import { computeDiff, type GitBufferExec } from './git-handler-ops' +import { gitCommit, gitInit, type MockDispatcher } from './git-handler-test-setup' +import { + createGitHandlerRelay, + createGitTempDir, + removeGitTempDir +} from './git-handler-test-harness' +import type { GitHandler } from './git-handler' + +describe('relay diff blob reads', () => { + let tmpDir: string + let dispatcher: MockDispatcher + let handler: GitHandler + + beforeEach(() => { + tmpDir = createGitTempDir() + ;({ dispatcher, handler } = createGitHandlerRelay()) + }) + + afterEach(async () => { + handler.dispose() + await removeGitTempDir(tmpDir) + }) + + it('shows an untracked text file named maxBuffer as text', async () => { + gitInit(tmpDir) + await writeFile(path.join(tmpDir, 'base.txt'), 'base\n') + gitCommit(tmpDir, 'initial') + await writeFile(path.join(tmpDir, 'maxBuffer'), 'plain text addition\n') + const result = await dispatcher.callRequest('git.diff', { + worktreePath: tmpDir, + filePath: 'maxBuffer', + staged: false + }) + expect(result).toMatchObject({ + kind: 'text', + originalContent: '', + modifiedContent: 'plain text addition\n' + }) + }) + + it('uses an empty index blob as the unstaged baseline without reading HEAD', async () => { + gitInit(tmpDir) + await writeFile(path.join(tmpDir, 'file.txt'), 'committed\n') + gitCommit(tmpDir, 'initial') + await writeFile(path.join(tmpDir, 'file.txt'), '') + const staged = await runProcess({ program: 'git', args: ['add', 'file.txt'], cwd: tmpDir }) + expect(staged.code).toBe(0) + await writeFile(path.join(tmpDir, 'file.txt'), 'working\n') + + const result = await dispatcher.callRequest('git.diff', { + worktreePath: tmpDir, + filePath: 'file.txt', + staged: false + }) + + expect(result).toMatchObject({ + kind: 'text', + originalContent: '', + modifiedContent: 'working\n' + }) + }) + + it('still falls back to HEAD after a staged deletion', async () => { + gitInit(tmpDir) + await writeFile(path.join(tmpDir, 'file.txt'), 'committed\n') + gitCommit(tmpDir, 'initial') + const removed = await runProcess({ + program: 'git', + args: ['rm', '--cached', 'file.txt'], + cwd: tmpDir + }) + expect(removed.code).toBe(0) + await writeFile(path.join(tmpDir, 'file.txt'), 'working\n') + + const result = await dispatcher.callRequest('git.diff', { + worktreePath: tmpDir, + filePath: 'file.txt', + staged: false + }) + + expect(result).toMatchObject({ + kind: 'text', + originalContent: 'committed\n', + modifiedContent: 'working\n' + }) + }) +}) + +describe('independent relay blob reads', () => { + function deferredBlobs() { + const releases: (() => void)[] = [] + const gitBuffer = vi.fn( + (args) => + new Promise((resolve) => { + const content = + args[2].startsWith(':') || args[2].startsWith('b'.repeat(40)) + ? 'modified\n' + : 'original\n' + releases.push(() => resolve(Buffer.from(content))) + }) + ) + return { gitBuffer, releases } + } + + it.each(['staged', 'commit'] as const)( + 'starts both %s sides before either completes', + async (kind) => { + const { gitBuffer, releases } = deferredBlobs() + const resultPromise = + kind === 'staged' + ? computeDiff(gitBuffer, '/repo', 'file.txt', true) + : commitDiffEntry(gitBuffer, '/repo', { + commitOid: 'b'.repeat(40), + parentOid: 'a'.repeat(40), + filePath: 'file.txt' + }) + const readsStartedTogether = gitBuffer.mock.calls.length + // Why iterative: the old sequential implementation starts its second read after the first resolves. + for (let i = 0; i < 2; i += 1) { + releases[i]?.() + await Promise.resolve() + await Promise.resolve() + } + const result = await resultPromise + + expect(readsStartedTogether).toBe(2) + expect(gitBuffer).toHaveBeenCalledTimes(2) + expect(result).toMatchObject({ + kind: 'text', + originalContent: 'original\n', + modifiedContent: 'modified\n' + }) + } + ) + + it('reads only the right side for a root commit', async () => { + const gitBuffer = vi.fn().mockResolvedValue(Buffer.from('added\n')) + const result = await commitDiffEntry(gitBuffer, '/repo', { + commitOid: 'b'.repeat(40), + filePath: 'file.txt' + }) + + expect(gitBuffer).toHaveBeenCalledTimes(1) + expect(result).toMatchObject({ originalContent: '', modifiedContent: 'added\n' }) + }) +}) diff --git a/src/relay/git-handler-diff-retry.test.ts b/src/relay/git-handler-diff-retry.test.ts new file mode 100644 index 00000000000..14dd0a4eefa --- /dev/null +++ b/src/relay/git-handler-diff-retry.test.ts @@ -0,0 +1,81 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createGitHandlerRelay } from './git-handler-test-harness' +import type { GitHandlerOperationHost } from './git-handler-operation-context' + +function deferredBlob() { + let resolve: (value: Buffer) => void = () => { + throw new Error('Deferred promise is not initialized') + } + const promise = new Promise((nextResolve) => { + resolve = nextResolve + }) + return { promise, resolve } +} + +const comparisons = [ + { method: 'git.diff', params: { staged: true } }, + { + method: 'git.branchDiff', + params: { baseRef: 'a'.repeat(40), headOid: 'b'.repeat(40), includePatch: true } + }, + { + method: 'git.commitDiff', + params: { parentOid: 'a'.repeat(40), commitOid: 'b'.repeat(40) } + } +] + +beforeEach(() => vi.useFakeTimers()) +afterEach(() => vi.useRealTimers()) + +describe('relay diff retries after a hung read', () => { + it.each(comparisons)( + '$method starts a fresh read after 30 seconds', + async ({ method, params }) => { + const { handler, dispatcher } = createGitHandlerRelay() + const oldBlob = deferredBlob() + const freshBlob = deferredBlob() + const signals: AbortSignal[] = [] + const gitBuffer = vi.fn( + async (_args, _cwd, options) => { + if (!options?.signal) { + throw new Error('The diff read did not receive its shared cancellation signal') + } + signals.push(options.signal) + return signals.length <= 2 ? oldBlob.promise : freshBlob.promise + } + ) + Object.assign(handler, { gitBuffer, git: async () => ({ stdout: '', stderr: '' }) }) + const request = { worktreePath: '/repo', filePath: 'file.txt', ...params } + const resultFor = (content: string) => { + const diff = { originalContent: content, modifiedContent: content } + return method === 'git.branchDiff' ? [diff] : diff + } + try { + const first = dispatcher.callRequest(method, request) + await vi.advanceTimersByTimeAsync(29_999) + const joined = dispatcher.callRequest(method, request) + await vi.advanceTimersByTimeAsync(0) + expect(gitBuffer).toHaveBeenCalledTimes(2) + + await vi.advanceTimersByTimeAsync(1) + const retry = dispatcher.callRequest(method, request) + await vi.advanceTimersByTimeAsync(0) + expect(gitBuffer).toHaveBeenCalledTimes(4) + expect(signals.every((signal) => !signal.aborted)).toBe(true) + + oldBlob.resolve(Buffer.from('old content\n')) + await expect(first).resolves.toMatchObject(resultFor('old content\n')) + await expect(joined).resolves.toMatchObject(resultFor('old content\n')) + const retryJoin = dispatcher.callRequest(method, request) + await vi.advanceTimersByTimeAsync(0) + expect(gitBuffer).toHaveBeenCalledTimes(4) + freshBlob.resolve(Buffer.from('fresh content\n')) + await expect(retry).resolves.toMatchObject(resultFor('fresh content\n')) + await expect(retryJoin).resolves.toMatchObject(resultFor('fresh content\n')) + expect(vi.getTimerCount()).toBe(0) + } finally { + handler.dispose() + } + } + ) +}) diff --git a/src/relay/git-handler-discard-operations.ts b/src/relay/git-handler-discard-operations.ts index a50bcf599a8..f5e6fc1770c 100644 --- a/src/relay/git-handler-discard-operations.ts +++ b/src/relay/git-handler-discard-operations.ts @@ -6,6 +6,7 @@ import { } from '../shared/git-discard-path-safety' import { partitionTrackedPathSpecs } from '../shared/git-tracked-pathspecs' import { detectConflictOperation } from './git-handler-status-ops' +import { encodeGitPathspecs } from '../shared/git-pathspec-stdin' const BULK_CHUNK_SIZE = GIT_BULK_CHUNK_SIZE @@ -46,7 +47,7 @@ export class GitHandlerDiscardOperations extends GitHandlerOperationContext { if (tracked) { await this.git( - ['restore', '--worktree', '--source=HEAD', '--', this.literalPathspec(filePath)], + ['restore', '--worktree', '--', this.literalPathspec(filePath)], worktreePath ) return @@ -96,19 +97,14 @@ export class GitHandlerDiscardOperations extends GitHandlerOperationContext { untrackedPaths, (targetPaths) => this.cleanUntrackedPaths(worktreePath, targetPaths), async () => { - for (let i = 0; i < trackedPaths.length; i += BULK_CHUNK_SIZE) { - const chunk = trackedPaths.slice(i, i + BULK_CHUNK_SIZE) - await this.git( - [ - 'restore', - '--worktree', - '--source=HEAD', - '--', - ...chunk.map((p) => this.literalPathspec(p)) - ], - worktreePath - ) + if (trackedPaths.length === 0) { + return } + await this.git( + ['restore', '--worktree', '--pathspec-from-file=-', '--pathspec-file-nul'], + worktreePath, + { stdin: encodeGitPathspecs(trackedPaths.map((p) => this.literalPathspec(p))) } + ) } ) } finally { diff --git a/src/relay/git-handler-object-diff-operations.ts b/src/relay/git-handler-object-diff-operations.ts index 819cf5e0f4a..f513741df04 100644 --- a/src/relay/git-handler-object-diff-operations.ts +++ b/src/relay/git-handler-object-diff-operations.ts @@ -22,7 +22,7 @@ export class GitHandlerObjectDiffOperations extends GitHandlerOperationContext { filePath: params.filePath as string | undefined, oldPath: params.oldPath as string | undefined } - const result = await this.gitDiffReadDedupe.run( + const result = await this.gitDiffReadDedupe.lease( stableInFlightKey([ 'branchDiff', worktreePath, @@ -32,7 +32,8 @@ export class GitHandlerObjectDiffOperations extends GitHandlerOperationContext { options.filePath ?? null, options.oldPath ?? null ]), - () => { + context?.signal, + (signal) => { if ( headOid && isFullGitObjectId(baseRef) && @@ -41,7 +42,7 @@ export class GitHandlerObjectDiffOperations extends GitHandlerOperationContext { options.filePath.length > 0 ) { return branchDiffEntryAtPinnedOids( - this.gitBuffer.bind(this), + this.gitBufferForSignal(signal), worktreePath, baseRef, headOid, @@ -50,8 +51,8 @@ export class GitHandlerObjectDiffOperations extends GitHandlerOperationContext { ) } return branchDiffEntries( - this.git.bind(this), - this.gitBuffer.bind(this), + this.gitForSignal(signal), + this.gitBufferForSignal(signal), worktreePath, baseRef, options @@ -69,7 +70,7 @@ export class GitHandlerObjectDiffOperations extends GitHandlerOperationContext { filePath: params.filePath as string, oldPath: params.oldPath as string | undefined } - const result = await this.gitDiffReadDedupe.run( + const result = await this.gitDiffReadDedupe.lease( stableInFlightKey([ 'commitDiff', worktreePath, @@ -78,7 +79,8 @@ export class GitHandlerObjectDiffOperations extends GitHandlerOperationContext { args.filePath, args.oldPath ?? null ]), - () => commitDiffEntry(this.gitBuffer.bind(this), worktreePath, args) + context?.signal, + (signal) => commitDiffEntry(this.gitBufferForSignal(signal), worktreePath, args) ) return this.maybeStreamResponse(result, params, context) } diff --git a/src/relay/git-handler-operation-context.ts b/src/relay/git-handler-operation-context.ts index 85d2d908097..928006f7290 100644 --- a/src/relay/git-handler-operation-context.ts +++ b/src/relay/git-handler-operation-context.ts @@ -1,5 +1,5 @@ import type { RequestContext } from './dispatcher' -import type { InFlightPromiseDedupe } from '../shared/in-flight-promise-dedupe' +import type { GitStatusReadLeaseOwner } from '../shared/git-status-read-lease-owner' import type { GitCapabilityCache } from '../shared/git-capability-cache' import type { SubmodulePathsCache } from './git-handler-submodule-ops' import type { RelayFilesystemWatchRegistry } from './relay-filesystem-watch-registry' @@ -20,7 +20,7 @@ export type GitHandlerCommandResult = { stdout: string; stderr: string } export type GitHandlerWatcherRegistry = Pick export type GitHandlerOperationHost = { - readonly gitDiffReadDedupe: InFlightPromiseDedupe + readonly gitDiffReadDedupe: GitStatusReadLeaseOwner readonly gitCapabilities: GitCapabilityCache readonly submodulePathsCache: SubmodulePathsCache readonly watcherRegistry: GitHandlerWatcherRegistry | undefined @@ -29,7 +29,7 @@ export type GitHandlerOperationHost = { cwd: string, opts?: GitHandlerCommandOptions ): Promise - gitBuffer(args: string[], cwd: string): Promise + gitBuffer(args: string[], cwd: string, opts?: GitHandlerCommandOptions): Promise spawnClone( args: string[], cwd: string, @@ -48,7 +48,7 @@ export type GitHandlerOperationHost = { export abstract class GitHandlerOperationContext { constructor(private readonly host: GitHandlerOperationHost) {} - protected get gitDiffReadDedupe(): InFlightPromiseDedupe { + protected get gitDiffReadDedupe(): GitStatusReadLeaseOwner { return this.host.gitDiffReadDedupe } @@ -72,8 +72,25 @@ export abstract class GitHandlerOperationContext { return this.host.git(args, cwd, opts) } - protected gitBuffer(args: string[], cwd: string): Promise { - return this.host.gitBuffer(args, cwd) + protected gitBuffer( + args: string[], + cwd: string, + opts?: GitHandlerCommandOptions + ): Promise { + return this.host.gitBuffer(args, cwd, opts) + } + + protected gitForSignal(signal?: AbortSignal) { + return signal + ? (args: string[], cwd: string, opts?: GitHandlerCommandOptions) => + this.git(args, cwd, { ...opts, signal }) + : this.git.bind(this) + } + + protected gitBufferForSignal(signal?: AbortSignal) { + return signal + ? (args: string[], cwd: string) => this.gitBuffer(args, cwd, { signal }) + : this.gitBuffer.bind(this) } protected spawnClone( diff --git a/src/relay/git-handler-ops.ts b/src/relay/git-handler-ops.ts index ba5c9e59699..788addc5bc8 100644 --- a/src/relay/git-handler-ops.ts +++ b/src/relay/git-handler-ops.ts @@ -6,9 +6,10 @@ * remain decoupled from the GitHandler class. */ import * as path from 'node:path' +import { isMissingGitBlobPath } from '../shared/git-blob-absence' import { bufferToBlob, parseBranchDiff } from './git-handler-utils' import { buildDiffResult } from './git-diff-result' -import { isGitBufferOverflowError } from './git-buffer-overflow' +import { isGitBufferOverflowError, isGitReadInterruptedError } from './git-buffer-overflow' import { readWorkingDiffFile } from './git-working-file-read' // ─── Executor types ────────────────────────────────────────────────── @@ -41,6 +42,9 @@ export async function readBlobAtOid( const buf = await gitBuffer(['show', '--end-of-options', `${oid}:${gitPath}`], cwd) return bufferToBlob(buf, filePath) } catch (error) { + if (isGitReadInterruptedError(error)) { + throw error + } if (isGitBufferOverflowError(error)) { return { content: '', isBinary: true } } @@ -59,12 +63,13 @@ export async function readBlobAtIndex( const buf = await gitBuffer(['show', '--end-of-options', `:${gitPath}`], cwd) return { ...bufferToBlob(buf, filePath), missing: false } } catch (error) { + if (isGitReadInterruptedError(error)) { + throw error + } if (isGitBufferOverflowError(error)) { return { content: '', isBinary: true, missing: false } } - // Why: a non-overflow failure means the path is absent from the index (a - // staged deletion), distinct from the size-capped case handled above. - return { content: '', isBinary: false, missing: true } + return { content: '', isBinary: false, missing: isMissingGitBlobPath(error, gitPath) } } } @@ -74,7 +79,7 @@ export async function readUnstagedLeft( filePath: string ): Promise<{ content: string; isBinary: boolean }> { const index = await readBlobAtIndex(gitBuffer, cwd, filePath) - if (index.content || index.isBinary) { + if (!index.missing) { return index } return readBlobAtOid(gitBuffer, cwd, 'HEAD', filePath) @@ -97,11 +102,12 @@ export async function computeDiff( try { if (staged) { - const left = await readBlobAtOid(git, worktreePath, 'HEAD', filePath) + const [left, right] = await Promise.all([ + readBlobAtOid(git, worktreePath, 'HEAD', filePath), + readBlobAtIndex(git, worktreePath, filePath) + ]) originalContent = left.content originalIsBinary = left.isBinary - - const right = await readBlobAtIndex(git, worktreePath, filePath) modifiedContent = right.content modifiedIsBinary = right.isBinary modifiedDeleted = right.missing @@ -117,7 +123,10 @@ export async function computeDiff( modifiedIsBinary = right.isBinary modifiedDeleted = right.missing } - } catch { + } catch (error) { + if (isGitReadInterruptedError(error)) { + throw error + } // Fallback to empty } @@ -158,7 +167,10 @@ export async function branchCompare( try { const { stdout } = await git(['branch', '--show-current'], worktreePath) return stdout.trim() || 'HEAD' - } catch { + } catch (error) { + if (isGitReadInterruptedError(error)) { + throw error + } return 'HEAD' } } @@ -207,7 +219,10 @@ export async function branchCompare( const { stdout } = await git(['merge-base', baseOid, headOid], worktreePath) mergeBase = stdout.trim() summary.mergeBase = mergeBase - } catch { + } catch (error) { + if (isGitReadInterruptedError(error)) { + throw error + } 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: [] } @@ -251,7 +266,10 @@ export async function branchDiffEntries( const { stdout: mbOut } = await git(['merge-base', baseOid, headOid], worktreePath) mergeBase = mbOut.trim() - } catch { + } catch (error) { + if (isGitReadInterruptedError(error)) { + throw error + } return [] } @@ -292,7 +310,10 @@ export async function branchDiffEntries( const left = await readBlobAtOid(gitBuffer, worktreePath, mergeBase, oldP) const right = await readBlobAtOid(gitBuffer, worktreePath, headOid, fp) results.push(buildDiffResult(left.content, right.content, left.isBinary, right.isBinary, fp)) - } catch { + } catch (error) { + if (isGitReadInterruptedError(error)) { + throw error + } results.push({ kind: 'text', originalContent: '', diff --git a/src/relay/git-handler-push-target.test.ts b/src/relay/git-handler-push-target.test.ts index b6fe5e96ad0..0357c5614d1 100644 --- a/src/relay/git-handler-push-target.test.ts +++ b/src/relay/git-handler-push-target.test.ts @@ -19,32 +19,21 @@ function gitForConfig(config: { if (args[0] === 'symbolic-ref') { return { stdout: `${branch}\n`, stderr: '' } } - if (args[0] === 'config' && args[2] === `branch.${branch}.pushRemote`) { - if (config.pushRemote instanceof Error) { - throw config.pushRemote + if (args[0] === 'config' && args[1] === '--list') { + const values = [ + [`branch.${branch}.pushremote`, config.pushRemote], + ['remote.pushdefault', config.pushDefault], + [`branch.${branch}.remote`, config.branchRemote], + [`branch.${branch}.merge`, merge], + [`branch.${branch}.base`, config.base] + ] + return { + stdout: values + .filter(([, value]) => typeof value === 'string') + .map(([key, value]) => `${key}\n${value}\0`) + .join(''), + stderr: '' } - return { stdout: `${config.pushRemote ?? ''}\n`, stderr: '' } - } - if (args[0] === 'config' && args[2] === 'remote.pushDefault') { - if (config.pushDefault instanceof Error) { - throw config.pushDefault - } - return { stdout: `${config.pushDefault ?? ''}\n`, stderr: '' } - } - if (args[0] === 'config' && args[2] === `branch.${branch}.remote`) { - if (config.branchRemote instanceof Error) { - throw config.branchRemote - } - return { stdout: `${config.branchRemote ?? ''}\n`, stderr: '' } - } - if (args[0] === 'config' && args[2] === `branch.${branch}.merge`) { - return { stdout: `${merge}\n`, stderr: '' } - } - if (args[0] === 'config' && args[2] === `branch.${branch}.base`) { - if (config.base instanceof Error) { - throw config.base - } - return { stdout: `${config.base ?? ''}\n`, stderr: '' } } if (args[0] === 'remote' && args[1] === '-v') { return { @@ -83,6 +72,9 @@ describe('resolveRelayPushTarget', () => { remote: 'fork', refspec: 'HEAD:contributor/fix' }) + expect(git.mock.calls.filter(([args]) => args[0] === 'config')).toEqual([ + [['config', '--list', '-z'], '/repo'] + ]) }) it('does not combine remote.pushDefault with a base-branch merge target', async () => { diff --git a/src/relay/git-handler-read-operations.ts b/src/relay/git-handler-read-operations.ts index 5976573b5a3..cde06f53996 100644 --- a/src/relay/git-handler-read-operations.ts +++ b/src/relay/git-handler-read-operations.ts @@ -29,7 +29,7 @@ function resolveSubmoduleStatusArea( export class GitHandlerReadOperations extends GitHandlerOperationContext { async getStatus(params: Record, context: RequestContext) { - this.gitDiffReadDedupe.clear() + this.gitDiffReadDedupe.invalidate() return getStatusOp(this.git.bind(this), streamRelayGitStdout, params, { signal: context.signal }) @@ -86,16 +86,24 @@ export class GitHandlerReadOperations extends GitHandlerOperationContext { return workingResult } - async checkIgnored(params: Record) { - return checkIgnoredPathsOp(this.git.bind(this), params) + async checkIgnored(params: Record, context?: RequestContext) { + const result = await checkIgnoredPathsOp(this.gitForSignal(context?.signal), params) + context?.signal?.throwIfAborted() + return result } - async history(params: Record) { + async history(params: Record, context?: RequestContext) { const worktreePath = params.worktreePath as string - return loadGitHistoryFromExecutor(this.git.bind(this), worktreePath, { - limit: typeof params.limit === 'number' ? params.limit : undefined, - baseRef: typeof params.baseRef === 'string' ? params.baseRef : null - }) + const result = await loadGitHistoryFromExecutor( + this.gitForSignal(context?.signal), + worktreePath, + { + limit: typeof params.limit === 'number' ? params.limit : undefined, + baseRef: typeof params.baseRef === 'string' ? params.baseRef : null + } + ) + context?.signal?.throwIfAborted() + return result } async getDiff(params: Record, context?: RequestContext) { @@ -110,12 +118,13 @@ export class GitHandlerReadOperations extends GitHandlerOperationContext { const staged = params.staged as boolean const compareAgainstHead = params.compareAgainstHead as boolean | undefined // Why: register dedupe before awaiting so identical reads coalesce. - const result = await this.gitDiffReadDedupe.run( + const result = await this.gitDiffReadDedupe.lease( stableInFlightKey(['diff', worktreePath, filePath, staged, compareAgainstHead]), - async () => { + context?.signal, + async (signal) => { // Why: route gitlink roots to pointer diffs and inner files to their submodule worktree. const submodulePaths = await listSubmodulePathsCached( - this.git.bind(this), + this.gitForSignal(signal), worktreePath, this.submodulePathsCache ) @@ -125,7 +134,7 @@ export class GitHandlerReadOperations extends GitHandlerOperationContext { const normalizedFilePath = filePath.replace(/\\/g, '/').replace(/\/+$/, '') if (normalizedFilePath === matchedSubmodule) { return computeSubmodulePointerDiff( - this.git.bind(this), + this.gitForSignal(signal), worktreePath, matchedSubmodule, staged, @@ -138,7 +147,7 @@ export class GitHandlerReadOperations extends GitHandlerOperationContext { ) const innerPath = normalizedFilePath.slice(matchedSubmodule.length + 1) const { fromOid, toOid } = await resolveSubmoduleCommitRange( - this.git.bind(this), + this.gitForSignal(signal), worktreePath, matchedSubmodule, staged @@ -146,7 +155,7 @@ export class GitHandlerReadOperations extends GitHandlerOperationContext { // Why: a moved gitlink (clean worktree) keeps inner changes in committed history, so diff the two commits; otherwise read the working-tree blob. if (fromOid && toOid && fromOid !== toOid) { return buildSubmoduleInnerCommitRangeDiff( - this.gitBuffer.bind(this), + this.gitBufferForSignal(signal), submoduleWorktreePath, innerPath, fromOid, @@ -154,7 +163,7 @@ export class GitHandlerReadOperations extends GitHandlerOperationContext { ) } return computeDiff( - this.gitBuffer.bind(this), + this.gitBufferForSignal(signal), submoduleWorktreePath, innerPath, staged, @@ -163,7 +172,7 @@ export class GitHandlerReadOperations extends GitHandlerOperationContext { } } return computeDiff( - this.gitBuffer.bind(this), + this.gitBufferForSignal(signal), worktreePath, filePath, staged, diff --git a/src/relay/git-handler-registration.ts b/src/relay/git-handler-registration.ts index 02690b202c8..79bfa0c57b1 100644 --- a/src/relay/git-handler-registration.ts +++ b/src/relay/git-handler-registration.ts @@ -11,8 +11,8 @@ export function registerGitHandlers( dispatcher.onRequest('git.submoduleStatus', (p, context) => handlers.read.getSubmoduleStatus(p, context) ) - dispatcher.onRequest('git.checkIgnored', (p) => handlers.read.checkIgnored(p)) - dispatcher.onRequest('git.history', (p) => handlers.read.history(p)) + dispatcher.onRequest('git.checkIgnored', (p, context) => handlers.read.checkIgnored(p, context)) + dispatcher.onRequest('git.history', (p, context) => handlers.read.history(p, context)) dispatcher.onRequest('git.commit', (p) => handlers.changes.commit(p)) dispatcher.onRequest('git.diff', (p, context) => handlers.read.getDiff(p, context)) dispatcher.onRequest('git.stage', (p) => handlers.changes.stage(p)) @@ -26,9 +26,15 @@ export function registerGitHandlers( dispatcher.onRequest('git.discard', (p) => handlers.discard.discard(p)) dispatcher.onRequest('git.bulkDiscard', (p) => handlers.discard.bulkDiscard(p)) dispatcher.onRequest('git.conflictOperation', (p) => handlers.discard.conflictOperation(p)) - dispatcher.onRequest('git.branchCompare', (p) => handlers.comparison.branchCompare(p)) - dispatcher.onRequest('git.commitCompare', (p) => handlers.comparison.commitCompare(p)) - dispatcher.onRequest('git.upstreamStatus', (p) => handlers.comparison.upstreamStatus(p)) + dispatcher.onRequest('git.branchCompare', (p, context) => + handlers.comparison.branchCompare(p, context) + ) + dispatcher.onRequest('git.commitCompare', (p, context) => + handlers.comparison.commitCompare(p, context) + ) + dispatcher.onRequest('git.upstreamStatus', (p, context) => + handlers.comparison.upstreamStatus(p, context) + ) dispatcher.onRequest('git.fetch', (p) => handlers.fetch.fetch(p)) dispatcher.onRequest('git.forkSync', (p, context) => handlers.fetch.forkSync(p, context)) dispatcher.onRequest('git.fetchRemoteTrackingRef', (p) => diff --git a/src/relay/git-handler-status-ops.ts b/src/relay/git-handler-status-ops.ts index 6a87bcd437d..33c014f227e 100644 --- a/src/relay/git-handler-status-ops.ts +++ b/src/relay/git-handler-status-ops.ts @@ -98,6 +98,8 @@ export async function getStatusOp( const statusArgs = [ '-c', 'core.quotePath=false', + '-c', + 'diff.autoRefreshIndex=false', 'status', '--porcelain=v2', '--branch', @@ -251,7 +253,17 @@ async function runNumstat( ): Promise | null> { try { const { stdout } = await git( - ['-c', 'core.quotePath=false', 'diff', ...(cached ? ['--cached'] : []), '--numstat', '-M'], + [ + '-c', + 'core.quotePath=false', + '-c', + 'diff.autoRefreshIndex=false', + 'diff', + ...(cached ? ['--cached'] : []), + '-z', + '--numstat', + '-M' + ], worktreePath, { disableOptionalLocks: true, signal } ) diff --git a/src/relay/git-handler-submodule-ops.test.ts b/src/relay/git-handler-submodule-ops.test.ts index 15481097688..1dd86946539 100644 --- a/src/relay/git-handler-submodule-ops.test.ts +++ b/src/relay/git-handler-submodule-ops.test.ts @@ -27,6 +27,27 @@ function gitmodulesExec(paths: string[]): { git: GitExec; calls: () => number } } describe('listSubmodulePathsCached', () => { + it.each([ + new DOMException('Canceled', 'AbortError'), + Object.assign(new Error('Deadline exceeded'), { timedOut: true }), + Object.assign(new Error('Child terminated'), { killed: true }) + ])('does not cache an interrupted catalog read: %s', async (error) => { + let calls = 0 + const git: GitExec = async () => { + if (++calls === 1) { + throw error + } + return { stdout: 'submodule.lib.path vendor/lib\n', stderr: '' } + } + const cache = createSubmodulePathsCache() + await expect(listSubmodulePathsCached(git, '/repo', cache, 1_000)).rejects.toBe(error) + expect(getSubmodulePathsCacheCount(cache)).toBe(0) + await expect(listSubmodulePathsCached(git, '/repo', cache, 1_001)).resolves.toEqual([ + 'vendor/lib' + ]) + expect(calls).toBe(2) + }) + it('reads .gitmodules once for repeated diffs on the same worktree within TTL', async () => { const { git, calls } = gitmodulesExec(['vendor/lib']) const cache = createSubmodulePathsCache() diff --git a/src/relay/git-handler-submodule-ops.ts b/src/relay/git-handler-submodule-ops.ts index 0409ba61177..0e3a0b96f88 100644 --- a/src/relay/git-handler-submodule-ops.ts +++ b/src/relay/git-handler-submodule-ops.ts @@ -9,8 +9,8 @@ */ import * as path from 'node:path' import { buildDiffResult } from './git-diff-result' -import { parseBranchDiff } from './git-handler-utils' -import { parseNumstat } from '../shared/git-uncommitted-line-stats' +import { isGitReadInterruptedError } from './git-buffer-overflow' +import { gitChangeListArgs, parseGitChangeList } from '../shared/git-change-list' import { readBlobAtOid, type GitBufferExec, type GitExec } from './git-handler-ops' /** @@ -133,7 +133,10 @@ export async function listSubmodulePaths(git: GitExec, worktreePath: string): Pr .replace(/\/+$/, '') }) .filter((value) => value.length > 0) - } catch { + } catch (error) { + if (isGitReadInterruptedError(error)) { + throw error + } return [] } } @@ -236,28 +239,12 @@ export async function computeSubmoduleRangeEntries( fromOid: string, toOid: string ): Promise[]> { - let nameStatus = '' - let numstat = '' try { - const [statusResult, numstatResult] = await Promise.all([ - git( - ['-c', 'core.quotePath=false', 'diff', '--name-status', '-M', '-C', fromOid, toOid], - submoduleWorktreePath - ), - git( - ['-c', 'core.quotePath=false', 'diff', '-z', '--numstat', '-M', '-C', fromOid, toOid], - submoduleWorktreePath - ) - ]) - nameStatus = statusResult.stdout - numstat = numstatResult.stdout + const { stdout } = await git(gitChangeListArgs(fromOid, toOid), submoduleWorktreePath) + return parseGitChangeList(stdout).map((entry) => ({ ...entry, area: 'unstaged' })) } catch { return [] } - return parseBranchDiff(nameStatus, parseNumstat(numstat)).map((entry) => ({ - ...entry, - area: 'unstaged' - })) } /** diff --git a/src/relay/git-handler-working-tree-changes.test.ts b/src/relay/git-handler-working-tree-changes.test.ts index d28c2127162..699b3f02fa9 100644 --- a/src/relay/git-handler-working-tree-changes.test.ts +++ b/src/relay/git-handler-working-tree-changes.test.ts @@ -319,18 +319,15 @@ describe('GitHandler', () => { expect(gitMock.mock.calls.map(([args]) => args)).toEqual([ ['ls-files', '-z', '--', ...filePaths.map((filePath) => `:(literal)${filePath}`)], - [ - 'restore', - '--worktree', - '--source=HEAD', - '--', - ':(literal)docs\\', - ':(literal)[ab].txt', - ':(literal)docs///', - ':(literal)docs\\' - ], + ['restore', '--worktree', '--pathspec-from-file=-', '--pathspec-file-nul'], ['clean', '-ffdx', '--', ':(literal)new', ':(literal)new', ':(literal)src/file'] ]) + expect(gitMock).toHaveBeenNthCalledWith( + 2, + ['restore', '--worktree', '--pathspec-from-file=-', '--pathspec-file-nul'], + tmpDir, + { stdin: ':(literal)docs\\\0:(literal)[ab].txt\0:(literal)docs///\0:(literal)docs\\\0' } + ) }) it('handles large tracked path lists during bulk discard classification', async () => { @@ -354,8 +351,9 @@ describe('GitHandler', () => { expect(gitMock).toHaveBeenNthCalledWith( 2, - ['restore', '--worktree', '--source=HEAD', '--', ':(literal)docs'], - tmpDir + ['restore', '--worktree', '--pathspec-from-file=-', '--pathspec-file-nul'], + tmpDir, + { stdin: ':(literal)docs\0' } ) }) diff --git a/src/relay/git-handler-worktree-change-operations.ts b/src/relay/git-handler-worktree-change-operations.ts index 907e5dba50d..63169b8a2e7 100644 --- a/src/relay/git-handler-worktree-change-operations.ts +++ b/src/relay/git-handler-worktree-change-operations.ts @@ -1,7 +1,6 @@ -import { GitHandlerOperationContext, GIT_BULK_CHUNK_SIZE } from './git-handler-operation-context' +import { GitHandlerOperationContext } from './git-handler-operation-context' import { commitChangesRelay } from './git-handler-worktree-ops' - -const BULK_CHUNK_SIZE = GIT_BULK_CHUNK_SIZE +import { encodeGitPathspecs } from '../shared/git-pathspec-stdin' export class GitHandlerWorktreeChangeOperations extends GitHandlerOperationContext { async stage(params: Record) { @@ -31,7 +30,8 @@ export class GitHandlerWorktreeChangeOperations extends GitHandlerOperationConte const worktreePath = params.worktreePath as string const filePath = params.filePath as string try { - await this.git(['restore', '--staged', '--', this.literalPathspec(filePath)], worktreePath) + // Reset treats an unborn HEAD as an empty tree, preserving the working file. + await this.git(['reset', '--quiet', '--', this.literalPathspec(filePath)], worktreePath) } finally { this.clearGitMutationReadCaches() } @@ -42,13 +42,12 @@ export class GitHandlerWorktreeChangeOperations extends GitHandlerOperationConte const worktreePath = params.worktreePath as string const filePaths = params.filePaths as string[] try { - for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) { - const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) - await this.git( - ['add', '--', ...chunk.map((filePath) => this.literalPathspec(filePath))], - worktreePath - ) + if (filePaths.length === 0) { + return } + await this.git(['add', '--pathspec-from-file=-', '--pathspec-file-nul'], worktreePath, { + stdin: encodeGitPathspecs(filePaths.map((filePath) => this.literalPathspec(filePath))) + }) } finally { this.clearGitMutationReadCaches() } @@ -59,13 +58,14 @@ export class GitHandlerWorktreeChangeOperations extends GitHandlerOperationConte const worktreePath = params.worktreePath as string const filePaths = params.filePaths as string[] try { - for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) { - const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) - await this.git( - ['restore', '--staged', '--', ...chunk.map((filePath) => this.literalPathspec(filePath))], - worktreePath - ) + if (filePaths.length === 0) { + return } + await this.git( + ['reset', '--quiet', '--pathspec-from-file=-', '--pathspec-file-nul'], + worktreePath, + { stdin: encodeGitPathspecs(filePaths.map((filePath) => this.literalPathspec(filePath))) } + ) } finally { this.clearGitMutationReadCaches() } diff --git a/src/relay/git-handler-worktree-list.ts b/src/relay/git-handler-worktree-list.ts index 18820d44d0b..fe784be8d4e 100644 --- a/src/relay/git-handler-worktree-list.ts +++ b/src/relay/git-handler-worktree-list.ts @@ -1,3 +1,5 @@ +import { annotateWorktreeLocksFromAdmin } from '../shared/git-worktree-admin' +import { expandTilde } from './context' import { stat } from 'node:fs/promises' import type { GitCapabilityCache } from '../shared/git-capability-cache' import type { GitExec } from './git-handler-ops' @@ -27,7 +29,9 @@ export async function readRelayWorktreeList( async () => { // Why: `-z` preserves newlines; fallback keeps Git <2.36 compatible. const { stdout } = await git(['worktree', 'list', '--porcelain'], repoPath) - return normalizeRelayWorktrees(parseWorktreeList(stdout)) + return normalizeRelayWorktrees( + await annotateWorktreeLocksFromAdmin(expandTilde(repoPath), parseWorktreeList(stdout)) + ) }, isUnsupportedWorktreeListZError ) @@ -55,8 +59,7 @@ export async function annotatePrunableWorktreesByExistence( const worktreePath = worktree?.path ?? '' // Git only marks linked worktrees prunable, and never locked ones (a // lock shields the registration even when the directory is missing). The - // `locked` annotation is only parsed on Git >=2.31, so on older Git a - // locked+missing worktree cannot be shielded here. A missing main + // Older Git locks are annotated from the host admin directory. A missing main // worktree is surfaced by the repo-level failure paths. if ( !worktreePath || diff --git a/src/relay/git-handler-worktree-operations.ts b/src/relay/git-handler-worktree-operations.ts index 261463f10c1..51b57c742d2 100644 --- a/src/relay/git-handler-worktree-operations.ts +++ b/src/relay/git-handler-worktree-operations.ts @@ -1,3 +1,4 @@ +import { annotateWorktreeLocksFromAdmin } from '../shared/git-worktree-admin' import * as path from 'node:path' import type { RequestContext } from './dispatcher' import { expandTilde } from './context' @@ -155,7 +156,11 @@ export class GitHandlerWorktreeOperations extends GitHandlerOperationContext { }) const normalized = await this.normalizeMainWorktreePath(repoPath, parseWorktreeList(stdout)) // Why: Git <2.31 emits no `prunable` annotation, so probe each linked worktree's existence instead of trusting stale registrations (issue #8389). - return annotatePrunableWorktreesByExistence(normalized) + return annotatePrunableWorktreesByExistence( + await annotateWorktreeLocksFromAdmin(expandTilde(repoPath), normalized, { + signal: context?.signal + }) + ) }, isUnsupportedWorktreeListZError ) diff --git a/src/relay/git-handler-worktree-ops.test.ts b/src/relay/git-handler-worktree-ops.test.ts index 0e7dfd282fc..452159fe2a2 100644 --- a/src/relay/git-handler-worktree-ops.test.ts +++ b/src/relay/git-handler-worktree-ops.test.ts @@ -282,7 +282,7 @@ describe('removeWorktreeOp', () => { ]) }) - it('force-retries removal when git refuses a clean worktree containing an initialised submodule', async () => { + it('preserves Git refusal even when parent status cannot reveal unpublished submodule commits', async () => { const calls: string[] = [] let listCount = 0 const git = vi.fn(async (args, cwd) => { @@ -311,19 +311,17 @@ describe('removeWorktreeOp', () => { return { stdout: '', stderr: '' } }) - await removeWorktreeWithCapabilityCache(git, { worktreePath: '/repo-feature' }) - + await expect( + removeWorktreeWithCapabilityCache(git, { worktreePath: '/repo-feature' }) + ).rejects.toThrow('git worktree remove failed') expect(calls).toEqual([ '/repo-feature$ rev-parse --git-common-dir', `${resolvedRepoPath()}$ worktree list --porcelain -z`, - `${resolvedRepoPath()}$ worktree remove /repo-feature`, - '/repo-feature$ status --porcelain --untracked-files=all', - `${resolvedRepoPath()}$ worktree remove --force /repo-feature`, - `${resolvedRepoPath()}$ branch -d -- feature/test` + `${resolvedRepoPath()}$ worktree remove /repo-feature` ]) }) - it('surfaces uncommitted changes instead of force-removing a dirty submodule worktree', async () => { + it('preserves Git refusal for a dirty submodule worktree', async () => { const git = vi.fn(async (args) => { if (args[0] === 'rev-parse') { return { stdout: '/repo/.git\n', stderr: '' } @@ -350,7 +348,7 @@ describe('removeWorktreeOp', () => { await expect( removeWorktreeWithCapabilityCache(git, { worktreePath: '/repo-feature' }) - ).rejects.toThrow('Worktree has uncommitted or untracked changes.') + ).rejects.toThrow('git worktree remove failed') expect(git).not.toHaveBeenCalledWith( ['worktree', 'remove', '--force', '/repo-feature'], expect.any(String) diff --git a/src/relay/git-handler-worktree-paths.test.ts b/src/relay/git-handler-worktree-paths.test.ts index d5f7a7755cb..11d1a866cd6 100644 --- a/src/relay/git-handler-worktree-paths.test.ts +++ b/src/relay/git-handler-worktree-paths.test.ts @@ -1,9 +1,15 @@ +import type * as WorktreeAdmin from '../shared/git-worktree-admin' import { describe, expect, it, vi } from 'vitest' import * as path from 'node:path' import { GitCapabilityCache } from '../shared/git-capability-cache' import type { GitExec } from './git-handler-ops' import { removeWorktreeOp } from './git-handler-worktree-ops' +vi.mock('../shared/git-worktree-admin', async (importActual) => ({ + ...(await importActual()), + annotateWorktreeLocksFromAdmin: async (_repoPath: string, rows: unknown[]) => rows +})) + function removeWorktreeWithCapabilityCache( git: GitExec, params: Parameters[1] diff --git a/src/relay/git-handler-worktree-remove.ts b/src/relay/git-handler-worktree-remove.ts index bf8e65a066e..dd47498c479 100644 --- a/src/relay/git-handler-worktree-remove.ts +++ b/src/relay/git-handler-worktree-remove.ts @@ -2,7 +2,6 @@ import * as path from 'node:path' import type { RemoveWorktreeResult } from '../shared/worktree/create-types' import { isBranchCheckedOutInWorktreeError } from '../shared/git-branch-delete-refusal' import { assertWorktreeUnlockedForRemoval } from '../shared/worktree/removal' -import { isSubmoduleWorktreeRemovalRefusal } from '../shared/worktree/submodule-removal' import { deleteAlreadyMergedRelayBranchAfterSafeDeleteFailure } from './git-handler-branch-cleanup' import type { GitExec } from './git-handler-ops' import type { GitCapabilityCache } from '../shared/git-capability-cache' @@ -135,23 +134,7 @@ export async function removeWorktreeOp( args.push('--force') } args.push(worktreePath) - try { - await git(args, repoPath) - } catch (error) { - if (force || !isSubmoduleWorktreeRemovalRefusal(error)) { - throw error - } - // Why: Git refuses non-force removal of any worktree with an initialised - // submodule even when everything is clean. Re-prove cleanliness (parent - // status reports dirty submodule content as ` M `), then --force. - const { stdout } = await git(['status', '--porcelain', '--untracked-files=all'], worktreePath) - if (stdout.trim()) { - const dirtyError = new Error('Worktree has uncommitted or untracked changes.') - ;(dirtyError as Error & { stdout?: string }).stdout = stdout - throw dirtyError - } - await git(['worktree', 'remove', '--force', worktreePath], repoPath) - } + await git(args, repoPath) if (!branchName) { return {} diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index 66bbd6d3cd1..e58b1e4abe4 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -1,9 +1,8 @@ -import { execFile, spawn, type ExecFileOptions } from 'node:child_process' -import { promisify } from 'node:util' import type { RelayDispatcher, RequestContext } from './dispatcher' import type { RelayContext } from './context' import { expandTilde } from './context' -import { InFlightPromiseDedupe } from '../shared/in-flight-promise-dedupe' +import { MAX_IN_FLIGHT_PROMISE_DEDUPE_ENTRIES } from '../shared/in-flight-promise-dedupe' +import { GitStatusReadLeaseOwner } from '../shared/git-status-read-lease-owner' import { GitCapabilityCache } from '../shared/git-capability-cache' import { clearSubmodulePathsCache, @@ -23,49 +22,20 @@ import type { import { createGitHandlerOperationSet } from './git-handler-operation-set' import { registerGitHandlers } from './git-handler-registration' import { resolveGitFetchHeadCommand, runWithGitFetchHeadLock } from '../shared/git-fetch-head-lock' -import { endSubprocessStdin } from '../shared/subprocess-stdin-write' import { MAX_GIT_BUFFER, runGitToTermination } from './git-handler-command-termination' - -const execFileAsync = promisify(execFile) - -function execFileWithStdin( - command: string, - args: string[], - options: ExecFileOptions, - stdin: string -): Promise<{ stdout: string; stderr: string }> { - return new Promise((resolve, reject) => { - let settled = false - const finish = ( - error: Error | null, - stdout: string | Buffer = '', - stderr: string | Buffer = '' - ): void => { - if (settled) { - return - } - settled = true - if (error) { - reject(Object.assign(error, { stdout, stderr })) - return - } - resolve({ stdout: String(stdout), stderr: String(stderr) }) - } - const child = execFile(command, args, options, (error, stdout, stderr) => { - if (error) { - finish(error, stdout, stderr) - return - } - finish(null, stdout, stderr) - }) - child.once('error', (error) => finish(error)) - endSubprocessStdin(child.stdin, stdin) - }) -} +import { classifyGitCommand, findGitSubcommandIndex } from '../shared/git-command-classification' +import { + GIT_SSH_CONFIG_ARGS, + parseGitSshConfig, + buildGitSshPolicyEnv +} from '../shared/git-ssh-policy-env' export class GitHandler { private dispatcher: RelayDispatcher - private readonly gitDiffReadDedupe = new InFlightPromiseDedupe() + private readonly gitDiffReadDedupe = new GitStatusReadLeaseOwner( + MAX_IN_FLIGHT_PROMISE_DEDUPE_ENTRIES, + 30_000 + ) private readonly gitCapabilities = new GitCapabilityCache() // Why: cache .gitmodules per instance to avoid SSH reads and test leakage. private submodulePathsCache: SubmodulePathsCache = createSubmodulePathsCache() @@ -89,7 +59,7 @@ export class GitHandler { watcherRegistry: this.watcherRegistry, git: (args, cwd, opts) => opts === undefined ? this.git(args, cwd) : this.git(args, cwd, opts), - gitBuffer: (args, cwd) => this.gitBuffer(args, cwd), + gitBuffer: (args, cwd, opts) => this.gitBuffer(args, cwd, opts), spawnClone: (args, cwd, progressId, context) => this.spawnClone(args, cwd, progressId, context), clearGitMutationReadCaches: () => this.clearGitMutationReadCaches(), @@ -137,7 +107,7 @@ export class GitHandler { } private clearGitMutationReadCaches(): void { - this.gitDiffReadDedupe.clear() + this.gitDiffReadDedupe.invalidate() invalidateGitBranchLineTotalInFlight() clearGitStatusLineStatsCache() clearSubmodulePathsCache(this.submodulePathsCache) @@ -160,26 +130,26 @@ export class GitHandler { ): Promise { const expandedCwd = expandTilde(cwd) const run = async (): Promise<{ stdout: string; stderr: string }> => { - const env = opts?.nonInteractive ? buildRelayUnattendedGitEnv() : buildRelayGitEnv() + const env = + classifyGitCommand(args) === 'network' + ? await this.networkSshEnv(args, expandedCwd, opts?.signal) + : opts?.nonInteractive + ? buildRelayUnattendedGitEnv() + : buildRelayGitEnv() if (opts?.disableOptionalLocks) { env.GIT_OPTIONAL_LOCKS = '0' } - const execOptions = { - cwd: expandedCwd, - env, - encoding: 'utf-8', - maxBuffer: opts?.maxBuffer ?? MAX_GIT_BUFFER, - timeout: opts?.timeout, - signal: opts?.signal - } satisfies ExecFileOptions - if (opts?.terminationBarrier) { - return runGitToTermination(args, execOptions, opts.stdin) - } - if (opts?.stdin !== undefined) { - return execFileWithStdin('git', args, execOptions, opts.stdin) - } - const { stdout, stderr } = await execFileAsync('git', args, execOptions) - return { stdout: String(stdout), stderr: String(stderr) } + return runGitToTermination( + args, + { + cwd: expandedCwd, + env, + maxBuffer: opts?.maxBuffer ?? MAX_GIT_BUFFER, + timeout: opts?.timeout, + signal: opts?.signal + }, + opts?.stdin + ) } const command = resolveGitFetchHeadCommand(args, expandedCwd) return command.needsLock @@ -187,14 +157,53 @@ export class GitHandler { : run() } - private async gitBuffer(args: string[], cwd: string): Promise { - const { stdout } = (await execFileAsync('git', args, { - cwd, - env: buildRelayGitEnv(), - encoding: 'buffer', - maxBuffer: MAX_GIT_BUFFER - })) as { stdout: Buffer } - return stdout + private async networkSshEnv( + args: readonly string[], + cwd: string, + signal?: AbortSignal + ): Promise { + const env = buildRelayUnattendedGitEnv() + if (env.GIT_SSH_COMMAND || env.GIT_SSH) { + return env + } + const subcommandIndex = findGitSubcommandIndex(args) + let config = parseGitSshConfig('') + try { + const { stdout } = await this.git( + [...args.slice(0, Math.max(0, subcommandIndex)), ...GIT_SSH_CONFIG_ARGS], + cwd, + { signal, timeout: 2500, nonInteractive: true } + ) + config = parseGitSshConfig(stdout) + } catch (error) { + if (!error || typeof error !== 'object' || !('code' in error) || error.code !== 1) { + throw error + } + } + return buildGitSshPolicyEnv(env, config.command, config.variant).env + } + + private async gitBuffer( + args: string[], + cwd: string, + opts?: GitHandlerCommandOptions + ): Promise { + const result = await runGitToTermination( + args, + { + cwd: expandTilde(cwd), + env: buildRelayGitEnv(), + captureStdoutAsBytes: true, + signal: opts?.signal, + timeout: opts?.timeout, + maxBuffer: opts?.maxBuffer + }, + undefined + ) + if (!result.stdoutBytes) { + throw new Error('Git byte capture returned no bytes.') + } + return result.stdoutBytes } private async spawnClone( @@ -203,63 +212,39 @@ export class GitHandler { progressId: string, context?: RequestContext ): Promise<{ stdout: string; stderr: string }> { - return await new Promise((resolve, reject) => { - const child = spawn('git', args, { - cwd: expandTilde(cwd), - env: buildRelayUnattendedGitEnv(), - stdio: ['ignore', 'pipe', 'pipe'] - }) - let stdout = '' - let stderr = '' - let settled = false - const cleanup = (): void => { - context?.signal?.removeEventListener('abort', onAbort) - } - const onAbort = (): void => { - child.kill() - } - context?.signal?.addEventListener('abort', onAbort, { once: true }) - child.stdout?.on('data', (chunk: Buffer) => { - stdout = (stdout + chunk.toString('utf-8')).slice(-4096) - }) - child.stderr?.on('data', (chunk: Buffer) => { - const text = chunk.toString('utf-8') - stderr = (stderr + text).slice(-4096) - for (const line of text.split(/[\r\n]+/)) { - const match = line.match(/^([\w\s]+):\s+(\d+)%/) - if (match) { - this.dispatcher.notify('git.cloneProgress', { - progressId, - phase: match[1].trim(), - percent: Number.parseInt(match[2], 10) - }) + const env = await this.networkSshEnv(args, expandTilde(cwd), context?.signal) + try { + const result = await runGitToTermination( + args, + { + cwd: expandTilde(cwd), + env, + signal: context?.signal, + maxBuffer: 4096, + outputCapture: 'tail', + observeStderr: (chunk) => { + for (const line of chunk.toString('utf8').split(/[\r\n]+/)) { + const match = line.match(/^([\w\s]+):\s+(\d+)%/) + if (match) { + this.dispatcher.notify('git.cloneProgress', { + progressId, + phase: match[1].trim(), + percent: Number.parseInt(match[2], 10) + }) + } + } } - } - }) - child.on('error', (error) => { - if (settled) { - return - } - settled = true - cleanup() - reject(error) - }) - child.on('close', (code, signal) => { - if (settled) { - return - } - settled = true - cleanup() - if (context?.signal?.aborted) { - reject(new Error('Clone aborted')) - return - } - if (code === 0 && !signal) { - resolve({ stdout, stderr }) - return - } - reject(new Error(`Clone failed: ${getGitCloneFailureMessage(stderr)}`)) - }) - }) + }, + undefined + ) + return result + } catch (error) { + if (context?.signal?.aborted) { + throw error + } + throw new Error( + `Clone failed: ${getGitCloneFailureMessage(error instanceof Error ? error.message : String(error))}` + ) + } } } diff --git a/src/relay/git-porcelain-local-parity.test.ts b/src/relay/git-porcelain-local-parity.test.ts index 9fccc85e7b3..aae3f4263cc 100644 --- a/src/relay/git-porcelain-local-parity.test.ts +++ b/src/relay/git-porcelain-local-parity.test.ts @@ -46,6 +46,8 @@ async function createWorktreeFixture(prefix: string): Promise { // directory would add a relay-only `prunable` annotation and muddy the comparison. await mkdir(path.join(mainPath, 'sparse-wt')) await mkdir(path.join(mainPath, 'locked-wt')) + await mkdir(path.join(mainPath, '.git')) + await writeFile(path.join(mainPath, '.git', 'HEAD'), 'ref: refs/heads/main\n') return mainPath } diff --git a/src/relay/git-push-target-local-parity.test.ts b/src/relay/git-push-target-local-parity.test.ts index 152b062d88e..ce97369b73d 100644 --- a/src/relay/git-push-target-local-parity.test.ts +++ b/src/relay/git-push-target-local-parity.test.ts @@ -61,6 +61,16 @@ function scriptGit(fixture: GitConfigFixture) { if (args[0] === 'symbolic-ref') { return { stdout: `${fixture.branch}\n`, stderr: '' } } + if (args[0] === 'config' && args[1] === '--list') { + return { + stdout: Array.from( + configValues, + ([key, value]) => + `${key.replace(/[^.]+$/, (variable) => variable.toLowerCase())}\n${value}\0` + ).join(''), + stderr: '' + } + } if (args[0] === 'config' && args[1] === '--get') { const value = configValues.get(args[2] ?? '') // Why throw: `git config --get` exits 1 for a missing key, and the resolver's @@ -99,6 +109,7 @@ async function pushOverRelay(fixture: GitConfigFixture): Promise { const script = scriptGit(fixture) vi.spyOn(handler as unknown as GitSpyTarget, 'git').mockImplementation((args) => script.run(args)) await dispatcher.callRequest('git.push', { worktreePath: WORKTREE_PATH }) + expect(script.calls.filter((args) => args[0] === 'config')).toEqual([['config', '--list', '-z']]) return pushArgv(script.calls) } @@ -106,6 +117,7 @@ async function pushLocally(fixture: GitConfigFixture): Promise { const script = scriptGit(fixture) gitExecFileAsyncMock.mockImplementation((args: string[]) => script.run(args)) await gitPush(WORKTREE_PATH) + expect(script.calls.filter((args) => args[0] === 'config')).toEqual([['config', '--list', '-z']]) return pushArgv(script.calls) } @@ -123,6 +135,35 @@ beforeEach(() => { }) describe('relay/desktop push-target parity', () => { + it.each([ + [ + { branch: 'feature/fix', pushRemote: 'fork', pushDefault: 'other', branchRemote: 'origin' }, + 'fork' + ], + [{ branch: 'feature/fix', pushDefault: 'fork', branchRemote: 'origin' }, 'fork'], + [{ branch: 'feature/fix', branchRemote: 'fork' }, 'fork'] + ] as const)( + 'first-publishes to the configured remote without branch.merge (%j)', + async (fixture, remote) => { + await expectSamePushArgv(fixture, ['push', '--set-upstream', remote, 'HEAD']) + } + ) + + it('normalizes a URL-valued remote on first publish', async () => { + await expectSamePushArgv( + { + branch: 'feature/fix', + pushRemote: 'git@example.invalid:contributor/repo.git', + remotes: { fork: 'git@example.invalid:contributor/repo.git' } + }, + ['push', '--set-upstream', 'fork', 'HEAD'] + ) + }) + + it('keeps a local-repository push remote out of first-publish targets', async () => { + await expectSamePushArgv({ branch: 'feature/fix', pushDefault: '.' }, FIRST_PUBLISH) + }) + it('sends a review branch to the fork its pushDefault names', async () => { await expectSamePushArgv( { diff --git a/src/relay/git-status-branch-line-total.test.ts b/src/relay/git-status-branch-line-total.test.ts index ed20750d217..35146c3249e 100644 --- a/src/relay/git-status-branch-line-total.test.ts +++ b/src/relay/git-status-branch-line-total.test.ts @@ -9,7 +9,10 @@ import { tmpdir } from 'node:os' import * as path from 'node:path' import { promisify } from 'node:util' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { invalidateGitBranchLineTotalInFlight } from '../shared/git-branch-line-total' +import { + invalidateGitBranchLineTotalInFlight, + isGitBranchLineTotalMergeBase +} from '../shared/git-branch-line-total' import { clearGitStatusLineStatsCache } from '../shared/git-status-line-stats-cache' import type { GitExec } from './git-handler-ops' import { getStatusOp } from './git-handler-status-ops' @@ -34,7 +37,12 @@ const STATUS_OUTPUT = [ ].join('\n') function isRangedNumstat(args: string[]): boolean { - return args.includes('diff') && args.includes('--numstat') && args.includes('-z') + return ( + args.includes('diff') && + args.includes('--numstat') && + args.includes('-z') && + isGitBranchLineTotalMergeBase(args.at(-2)) + ) } function rangedDiffCalls(calls: readonly GitCall[]): string[][] { @@ -62,10 +70,10 @@ function createMockGit(overrides: { return { stdout: overrides.status ?? STATUS_OUTPUT, stderr: '' } } if (isRangedNumstat(args)) { - return overrides.ranged ? overrides.ranged() : { stdout: '12\t5\tsrc/a.ts\n', stderr: '' } + return overrides.ranged ? overrides.ranged() : { stdout: '12\t5\tsrc/a.ts\0', stderr: '' } } if (args.includes('diff')) { - return { stdout: overrides.areaNumstat ?? '3\t2\tsrc/a.ts\n', stderr: '' } + return { stdout: overrides.areaNumstat ?? '3\t2\tsrc/a.ts\0', stderr: '' } } throw new Error(`Unexpected git command: ${args.join(' ')}`) }) @@ -173,7 +181,18 @@ describe('getStatusOp branch line total', () => { generated: NO_LINES }) expect(rangedDiffCalls(git.mock.calls)).toEqual([ - ['-c', 'core.quotePath=false', 'diff', '-z', '--numstat', '-M', mergeBase, '--'] + [ + '-c', + 'core.quotePath=false', + '-c', + 'diff.autoRefreshIndex=false', + 'diff', + '-z', + '--numstat', + '-M', + mergeBase, + '--' + ] ]) }) @@ -324,7 +343,7 @@ describe('getStatusOp branch line total', () => { throw error } if (args.includes('diff')) { - return { stdout: '3\t2\tsrc/a.ts\n', stderr: '' } + return { stdout: '3\t2\tsrc/a.ts\0', stderr: '' } } throw new Error(`Unexpected git command: ${args.join(' ')}`) }) @@ -373,10 +392,10 @@ describe('getStatusOp branch line total', () => { } if (isRangedNumstat(args)) { await new Promise((resolve) => setTimeout(resolve, 20)) - return { stdout: '12\t5\tsrc/a.ts\n', stderr: '' } + return { stdout: '12\t5\tsrc/a.ts\0', stderr: '' } } if (args.includes('diff')) { - return { stdout: '3\t2\tsrc/a.ts\n', stderr: '' } + return { stdout: '3\t2\tsrc/a.ts\0', stderr: '' } } throw new Error(`Unexpected git command: ${args.join(' ')}`) }) diff --git a/src/relay/git-status-upstream-negative-cache.ts b/src/relay/git-status-upstream-negative-cache.ts index f3dcd2df663..b598393314a 100644 --- a/src/relay/git-status-upstream-negative-cache.ts +++ b/src/relay/git-status-upstream-negative-cache.ts @@ -1,4 +1,3 @@ -import { createGitConfigSnapshotRunner } from '../shared/git-config-snapshot-runner' import { getEffectiveGitUpstreamStatus } from '../shared/git-effective-upstream' import type { GitCommandRunner } from '../shared/git-effective-upstream' import type { GitUpstreamStatus } from '../shared/git-status-types' @@ -116,13 +115,12 @@ export async function readOrProbeNoEffectiveUpstreamStatus( } let probedSameNameOriginRef = false - const snapshotRunner = createGitConfigSnapshotRunner(runGit) const writeGeneration = noEffectiveUpstreamWriteGeneration.get(cacheKey) ?? 0 const probe = getEffectiveGitUpstreamStatus((args) => { if (args[0] === 'rev-parse' && args.includes(`refs/remotes/origin/${identity.branchName}`)) { probedSameNameOriginRef = true } - return snapshotRunner(args) + return runGit(args) }).then((status) => { cacheNoEffectiveUpstreamStatus(cacheKey, status, probedSameNameOriginRef, writeGeneration) return status diff --git a/src/relay/git-stdout-stream.test.ts b/src/relay/git-stdout-stream.test.ts index bc41c4deba6..65145d7e204 100644 --- a/src/relay/git-stdout-stream.test.ts +++ b/src/relay/git-stdout-stream.test.ts @@ -1,16 +1,28 @@ +import type * as ProcessRunner from '../shared/child-process/run-process' +import type * as ProcessTreeTermination from '../shared/child-process/process-tree-termination' import { EventEmitter } from 'node:events' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { spawnMock, terminateMock } = vi.hoisted(() => ({ spawnMock: vi.fn(), terminateMock: vi.fn() })) -vi.mock('node:child_process', () => ({ spawn: spawnMock })) -vi.mock('./subprocess-tree-termination', () => ({ - terminateRelaySubprocessTree: terminateMock +vi.mock('../shared/child-process/run-process', async (importActual) => ({ + ...(await importActual()), + spawnProcess: spawnMock +})) +vi.mock('../shared/child-process/process-tree-termination', async (importActual) => ({ + ...(await importActual()), + forceTerminateProcessTree: terminateMock })) +import { GitAdmissionScheduler } from '../shared/git-admission-scheduler' +import { GIT_READ_TIMEOUT_MS } from '../shared/git-command-timeout' +import { + _resetRelayGitAdmissionForTests, + acquireRelayGitAdmission +} from './git-handler-command-termination' import { streamRelayGitStdout } from './git-stdout-stream' type MockChild = EventEmitter & { @@ -27,10 +39,21 @@ function createChild(): MockChild { return child } +async function waitForSpawn(count = 1): Promise { + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(count), { interval: 1 }) +} + describe('streamRelayGitStdout', () => { beforeEach(() => { spawnMock.mockReset() - terminateMock.mockReset() + terminateMock.mockReset().mockResolvedValue(true) + _resetRelayGitAdmissionForTests( + new GitAdmissionScheduler({ generalCap: 1, generalHeadroom: 0 }) + ) + }) + + afterEach(() => { + vi.useRealTimers() }) it('decodes split UTF-8 chunks and stops the child at the parser limit', async () => { @@ -44,6 +67,7 @@ describe('streamRelayGitStdout', () => { return output.includes('\n') } }) + await waitForSpawn() const bytes = Buffer.from('? café-😀.txt\n') const emojiStart = bytes.indexOf(Buffer.from('😀')) child.stdout.emit('data', bytes.subarray(0, emojiStart + 2)) @@ -53,16 +77,17 @@ describe('streamRelayGitStdout', () => { expect(output).toBe('? café-😀.txt\n') expect(terminateMock).toHaveBeenCalledWith(child) expect(spawnMock).toHaveBeenCalledWith( - 'git', - ['status', '--porcelain=v2'], expect.objectContaining({ + program: 'git', + args: ['status', '--porcelain=v2'], cwd: '/repo', env: expect.objectContaining({ GIT_OPTIONAL_LOCKS: '0' }), stdio: ['ignore', 'pipe', 'pipe'], - windowsHide: true + detached: process.platform !== 'win32' }) ) expect(child.stdout.listenerCount('data')).toBe(0) + child.emit('close', 0) }) it('rejects parser failures after terminating and detaching the child', async () => { @@ -73,6 +98,7 @@ describe('streamRelayGitStdout', () => { throw new Error('parser failed') } }) + await waitForSpawn() const rejection = expect(pending).rejects.toThrow('parser failed') child.stdout.emit('data', Buffer.from('? file.ts\n')) @@ -80,6 +106,8 @@ describe('streamRelayGitStdout', () => { expect(terminateMock).toHaveBeenCalledWith(child) expect(child.stdout.listenerCount('data')).toBe(0) expect(child.stderr.listenerCount('data')).toBe(0) + expect(child.listenerCount('close')).toBe(1) + child.emit('close', 0) expect(child.listenerCount('close')).toBe(0) }) @@ -91,11 +119,14 @@ describe('streamRelayGitStdout', () => { signal: controller.signal, onStdout: () => {} }) + await waitForSpawn() const rejection = expect(pending).rejects.toMatchObject({ name: 'AbortError' }) controller.abort() await rejection expect(terminateMock).toHaveBeenCalledWith(child) + expect(child.listenerCount('error')).toBe(1) + child.emit('close', 0) expect(child.listenerCount('error')).toBe(0) }) @@ -108,6 +139,7 @@ describe('streamRelayGitStdout', () => { signal: controller.signal, onStdout: () => {} }) + await waitForSpawn() const rejection = expect(pending).rejects.toMatchObject({ name: 'AbortError' }) controller.abort() @@ -124,6 +156,7 @@ describe('streamRelayGitStdout', () => { maxBuffer: 64, onStdout: () => {} }) + await waitForSpawn() const rejection = expect(pending).rejects.toThrow('git exited with 128: fatal: nope') child.stderr.emit('data', Buffer.from('fatal: nope')) child.emit('close', 128) @@ -133,4 +166,136 @@ describe('streamRelayGitStdout', () => { expect(child.stdout.listenerCount('data')).toBe(0) expect(child.stderr.listenerCount('data')).toBe(0) }) + + it('removes an aborted queued read without spawning it', async () => { + const held = await acquireRelayGitAdmission({ args: ['status'], cwd: '/repo' }) + const controller = new AbortController() + const pending = streamRelayGitStdout(['status'], '/repo', { + signal: controller.signal, + onStdout: () => {} + }) + const rejection = expect(pending).rejects.toMatchObject({ name: 'AbortError' }) + controller.abort() + await rejection + expect(spawnMock).not.toHaveBeenCalled() + held.release() + const child = createChild() + spawnMock.mockReturnValue(child) + const next = streamRelayGitStdout(['status'], '/repo', { onStdout: () => {} }) + await waitForSpawn() + child.emit('close', 0) + await expect(next).resolves.toEqual({ stoppedEarly: false }) + }) + + it('rechecks an abort after admission before spawning', async () => { + const controller = new AbortController() + const pending = streamRelayGitStdout(['status'], '/repo', { + signal: controller.signal, + onStdout: () => {} + }) + controller.abort() + await expect(pending).rejects.toMatchObject({ name: 'AbortError' }) + expect(spawnMock).not.toHaveBeenCalled() + const grant = await acquireRelayGitAdmission({ args: ['status'], cwd: '/repo' }) + grant.release() + }) + + it('returns a capped result immediately while retaining admission until child close', async () => { + const firstChild = createChild() + const secondChild = createChild() + spawnMock.mockReturnValueOnce(firstChild).mockReturnValueOnce(secondChild) + const first = streamRelayGitStdout(['status'], '/repo', { onStdout: () => true }) + await waitForSpawn() + firstChild.stdout.emit('data', Buffer.from('? capped\n')) + await expect(first).resolves.toEqual({ stoppedEarly: true }) + const second = streamRelayGitStdout(['status'], '/repo', { onStdout: () => {} }) + await Promise.resolve() + expect(spawnMock).toHaveBeenCalledOnce() + expect(() => firstChild.stderr.emit('error', new Error('late pipe error'))).not.toThrow() + firstChild.emit('close', null) + await waitForSpawn(2) + expect(spawnMock).toHaveBeenCalledTimes(2) + secondChild.emit('close', 0) + await second + }) + + it.each([undefined, 25])( + 'bounds an admitted read by its default or explicit deadline: %s', + async (timeout) => { + vi.useFakeTimers() + const child = createChild() + const nextChild = createChild() + spawnMock.mockReturnValueOnce(child).mockReturnValueOnce(nextChild) + const pending = streamRelayGitStdout(['status'], '/repo', { timeout, onStdout: () => {} }) + const rejection = expect(pending).rejects.toMatchObject({ + name: 'GitCommandTimeoutError', + timedOut: true + }) + await waitForSpawn() + await vi.advanceTimersByTimeAsync(timeout ?? GIT_READ_TIMEOUT_MS) + await rejection + expect(terminateMock).toHaveBeenCalledWith(child) + const next = streamRelayGitStdout(['status'], '/repo', { onStdout: () => {} }) + await Promise.resolve() + expect(spawnMock).toHaveBeenCalledOnce() + child.emit('close', null) + await waitForSpawn(2) + expect(spawnMock).toHaveBeenCalledTimes(2) + nextChild.emit('close', 0) + await next + expect(vi.getTimerCount()).toBe(0) + } + ) + + it('retains an aborted child grant until its close', async () => { + const child = createChild() + const nextChild = createChild() + spawnMock.mockReturnValueOnce(child).mockReturnValueOnce(nextChild) + const controller = new AbortController() + const pending = streamRelayGitStdout(['status'], '/repo', { + signal: controller.signal, + onStdout: () => {} + }) + const rejection = expect(pending).rejects.toMatchObject({ name: 'AbortError' }) + await waitForSpawn() + controller.abort() + await rejection + const next = streamRelayGitStdout(['status'], '/repo', { onStdout: () => {} }) + await Promise.resolve() + expect(spawnMock).toHaveBeenCalledOnce() + child.emit('close', null) + await waitForSpawn(2) + expect(spawnMock).toHaveBeenCalledTimes(2) + nextChild.emit('close', 0) + await next + }) + + it('releases admission on a synchronous spawn failure', async () => { + spawnMock.mockImplementationOnce(() => { + throw new Error('spawn failed') + }) + await expect(streamRelayGitStdout(['status'], '/repo', { onStdout: () => {} })).rejects.toThrow( + 'spawn failed' + ) + const child = createChild() + spawnMock.mockReturnValueOnce(child) + const next = streamRelayGitStdout(['status'], '/repo', { onStdout: () => {} }) + await waitForSpawn(2) + child.emit('close', 0) + await next + }) + + it('releases on confirmed asynchronous spawn failure without waiting for close', async () => { + const child = createChild() + child.pid = undefined + spawnMock.mockReturnValueOnce(child) + const pending = streamRelayGitStdout(['status'], '/repo', { onStdout: () => {} }) + const rejection = expect(pending).rejects.toThrow('ENOENT') + await waitForSpawn() + child.emit('error', new Error('ENOENT')) + await rejection + const grant = await acquireRelayGitAdmission({ args: ['status'], cwd: '/repo' }) + grant.release() + expect(child.listenerCount('close')).toBe(0) + }) }) diff --git a/src/relay/git-stdout-stream.ts b/src/relay/git-stdout-stream.ts index 2118af14342..914cccfd114 100644 --- a/src/relay/git-stdout-stream.ts +++ b/src/relay/git-stdout-stream.ts @@ -1,8 +1,11 @@ -import { spawn } from 'node:child_process' import { StringDecoder } from 'node:string_decoder' +import { spawnProcess } from '../shared/child-process/run-process' +import { forceTerminateProcessTree } from '../shared/child-process/process-tree-termination' +import { createChildTerminationReporter } from '../shared/child-process/child-termination-reporter' +import { GitCommandTimeoutError, gitCommandTimeoutMs } from '../shared/git-command-timeout' import { expandTilde } from './context' import { buildRelayGitEnv } from './relay-command-env' -import { terminateRelaySubprocessTree } from './subprocess-tree-termination' +import { acquireRelayGitAdmission } from './git-handler-command-termination' const DEFAULT_RELAY_GIT_STREAM_MAX_BYTES = 10 * 1024 * 1024 @@ -10,6 +13,7 @@ export type RelayGitStreamOptions = { disableOptionalLocks?: boolean signal?: AbortSignal maxBuffer?: number + timeout?: number onStdout: (chunk: string) => boolean | void } @@ -28,26 +32,32 @@ function createAbortError(): Error { /** Stream Git stdout on the relay host and allow the consumer to stop it early. */ export const streamRelayGitStdout: RelayGitStreamExec = async (args, cwd, options) => { const maxBuffer = options.maxBuffer ?? DEFAULT_RELAY_GIT_STREAM_MAX_BYTES + const resolvedCwd = expandTilde(cwd) + const grant = await acquireRelayGitAdmission({ args, cwd: resolvedCwd, signal: options.signal }) return new Promise((resolve, reject) => { if (options.signal?.aborted) { + grant.release() reject(createAbortError()) return } - const env = buildRelayGitEnv() - if (options.disableOptionalLocks) { - env.GIT_OPTIONAL_LOCKS = '0' - } - + const termination = createChildTerminationReporter(grant.release) let child try { - child = spawn('git', args, { - cwd: expandTilde(cwd), + const env = buildRelayGitEnv() + if (options.disableOptionalLocks) { + env.GIT_OPTIONAL_LOCKS = '0' + } + child = spawnProcess({ + program: 'git', + args, + cwd: resolvedCwd, env, stdio: ['ignore', 'pipe', 'pipe'], - windowsHide: true + detached: process.platform !== 'win32' }) } catch (error) { + termination.report() reject(error instanceof Error ? error : new Error(String(error))) return } @@ -61,13 +71,13 @@ export const streamRelayGitStdout: RelayGitStreamExec = async (args, cwd, option // stateful decoding keeps the porcelain record intact. const stdoutDecoder = new StringDecoder('utf8') const stderrDecoder = new StringDecoder('utf8') + let deadline: ReturnType | undefined 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) + clearTimeout(deadline) stdoutDecoder.end() stderrDecoder.end() } @@ -83,8 +93,16 @@ export const streamRelayGitStdout: RelayGitStreamExec = async (args, cwd, option resolve({ stoppedEarly }) } } + // Parser completion leaves the child holding admission until termination is observed. + const releaseChild = (): void => { + termination.report() + child.off('error', onError) + child.off('close', onClose) + child.stdout.off('error', onError) + child.stderr.off('error', onError) + } const stopWithError = (error: Error): void => { - terminateRelaySubprocessTree(child) + void forceTerminateProcessTree(child).catch(() => {}) finish(error) } @@ -103,7 +121,7 @@ export const streamRelayGitStdout: RelayGitStreamExec = async (args, cwd, option // Why: the status cap is a successful partial result, so detach and // resolve immediately after stopping Git instead of awaiting close. stoppedEarly = true - terminateRelaySubprocessTree(child) + void forceTerminateProcessTree(child).catch(() => {}) finish() } } catch (error) { @@ -119,9 +137,16 @@ export const streamRelayGitStdout: RelayGitStreamExec = async (args, cwd, option stderr += stderrDecoder.write(chunk) } function onError(error: Error): void { + if (!child.pid) { + releaseChild() + } + if (!settled && child.pid) { + void forceTerminateProcessTree(child).catch(() => {}) + } finish(error) } function onClose(code: number | null): void { + releaseChild() if (stoppedEarly || code === 0) { finish() } else { @@ -129,18 +154,23 @@ export const streamRelayGitStdout: RelayGitStreamExec = async (args, cwd, option } } function onAbort(): void { - if (!child.pid) { - // Why: failed spawn reports ENOENT after abort cleanup; handle it so it cannot crash the relay. - child.once('error', () => {}) - } stopWithError(createAbortError()) } child.stdout.on('data', onStdoutData) child.stderr.on('data', onStderrData) + child.stdout.on('error', onError) + child.stderr.on('error', onError) child.on('error', onError) child.on('close', onClose) options.signal?.addEventListener('abort', onAbort, { once: true }) + const timeoutMs = gitCommandTimeoutMs(args, options.timeout) + if (timeoutMs !== undefined && timeoutMs > 0) { + deadline = setTimeout(() => { + stopWithError(Object.assign(new GitCommandTimeoutError(timeoutMs), { timedOut: true })) + }, timeoutMs) + deadline.unref() + } if (options.signal?.aborted) { onAbort() } diff --git a/src/relay/relay-command-env.test.ts b/src/relay/relay-command-env.test.ts index dbdc99bf24f..847430dc8ce 100644 --- a/src/relay/relay-command-env.test.ts +++ b/src/relay/relay-command-env.test.ts @@ -270,7 +270,7 @@ describe('buildRelayUnattendedGitEnv', () => { expect(env.GIT_CONFIG_VALUE_0).toBe('false') expect(env.GIT_CONFIG_KEY_1).toBe('credential.guiPrompt') expect(env.GIT_CONFIG_VALUE_1).toBe('false') - expect(env.GIT_SSH_COMMAND).toBe('ssh -o BatchMode=yes') + expect(env.GIT_SSH_COMMAND).toBeUndefined() expect(env.LC_ALL).toBe('en_US.UTF-8') expect(env.PATH?.split(':')).toEqual(expect.arrayContaining(['/custom/bin', '/usr/bin'])) }) diff --git a/src/relay/relay-command-env.ts b/src/relay/relay-command-env.ts index 5f06650e17b..185ea848f81 100644 --- a/src/relay/relay-command-env.ts +++ b/src/relay/relay-command-env.ts @@ -178,6 +178,5 @@ export function buildRelayUnattendedGitEnv( // Why: SSH-host GCM can open its own OAuth window even though the clone's // stdin is ignored, leaving the relay request hung with no way to answer it. const env = gitCredentialPromptGuardEnv(buildRelayGitEnv(baseEnv, platform), platform) - env.GIT_SSH_COMMAND ??= 'ssh -o BatchMode=yes' return env } diff --git a/src/renderer/src/components/right-sidebar/source-control-discard-confirmation.test.ts b/src/renderer/src/components/right-sidebar/source-control-discard-confirmation.test.ts index 1b0b583d41d..fa5f4cfad55 100644 --- a/src/renderer/src/components/right-sidebar/source-control-discard-confirmation.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-discard-confirmation.test.ts @@ -26,15 +26,15 @@ describe('getDiscardEntryConfirmationCopy', () => { }) }) - it('uses delete copy for files added to the index', () => { + it('preserves staged additions when discarding working edits', () => { expect( getDiscardEntryConfirmationCopy( entry({ area: 'staged', path: 'src/added.ts', status: 'added' }) ) ).toEqual({ - title: 'Delete "added.ts"?', - description: 'This will permanently delete this file. This cannot be undone.', - confirmLabel: 'Delete' + title: 'Discard changes to "added.ts"?', + description: 'This will revert the unstaged changes to this file. This cannot be undone.', + confirmLabel: 'Discard' }) }) @@ -46,7 +46,7 @@ describe('getDiscardEntryConfirmationCopy', () => { ).toEqual({ title: 'Restore "removed.ts"?', description: - 'This will restore the file from HEAD and discard the deletion. This cannot be undone.', + 'This will restore the last staged version and discard the deletion. This cannot be undone.', confirmLabel: 'Restore' }) }) @@ -56,7 +56,7 @@ describe('getDiscardEntryConfirmationCopy', () => { getDiscardEntryConfirmationCopy(entry({ path: 'src/changed.ts', status: 'modified' })) ).toEqual({ title: 'Discard changes to "changed.ts"?', - description: 'This will revert all changes to this file. This cannot be undone.', + description: 'This will revert the unstaged changes to this file. This cannot be undone.', confirmLabel: 'Discard' }) }) diff --git a/src/renderer/src/components/right-sidebar/source-control-discard-localization.test.ts b/src/renderer/src/components/right-sidebar/source-control-discard-localization.test.ts new file mode 100644 index 00000000000..39b1b9e4048 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control-discard-localization.test.ts @@ -0,0 +1,32 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { i18n } from '../../i18n/i18n' +import { getDiscardEntryConfirmationCopy } from './source-control/commit/discard-confirmation' + +afterEach(async () => { + await i18n.changeLanguage('en') +}) + +describe('discard descriptions with real locale catalogs', () => { + it.each(['en', 'es', 'fr', 'ja', 'ko', 'zh'] as const)( + 'uses current index-preserving descriptions in %s', + async (locale) => { + await i18n.changeLanguage(locale) + expect( + getDiscardEntryConfirmationCopy({ + area: 'unstaged', + path: 'changed.txt', + status: 'modified' + }).description + ).toBe('This will revert the unstaged changes to this file. This cannot be undone.') + expect( + getDiscardEntryConfirmationCopy({ + area: 'unstaged', + path: 'removed.txt', + status: 'deleted' + }).description + ).toBe( + 'This will restore the last staged version and discard the deletion. This cannot be undone.' + ) + } + ) +}) diff --git a/src/renderer/src/components/right-sidebar/source-control/commit/discard-all-sequence.ts b/src/renderer/src/components/right-sidebar/source-control/commit/discard-all-sequence.ts index 2efdd5ad7a6..bc088a350c9 100644 --- a/src/renderer/src/components/right-sidebar/source-control/commit/discard-all-sequence.ts +++ b/src/renderer/src/components/right-sidebar/source-control/commit/discard-all-sequence.ts @@ -18,7 +18,11 @@ export function getDiscardAllPaths( entry.conflictStatus !== 'unresolved' && entry.conflictStatus !== 'resolved_locally' ) - .map((entry) => entry.path) + .flatMap((entry) => + area === 'staged' && entry.status === 'renamed' && entry.oldPath + ? [entry.path, entry.oldPath] + : [entry.path] + ) } export type StageAllArea = 'unstaged' | 'untracked' @@ -60,7 +64,11 @@ export function isSubmoduleWorktreeOnlyChange(entry: GitStatusEntry): boolean { * row is safe and mirrors the per-row Unstage action. */ export function getUnstageAllPaths(entries: readonly GitStatusEntry[]): string[] { - return entries.filter((entry) => entry.area === 'staged').map((entry) => entry.path) + return entries + .filter((entry) => entry.area === 'staged' && !entry.submoduleRoot) + .flatMap((entry) => + entry.status === 'renamed' && entry.oldPath ? [entry.path, entry.oldPath] : [entry.path] + ) } export type DiscardAllDeps = { @@ -71,7 +79,7 @@ export type DiscardAllDeps = { * keep the legacy per-file sequence in tests or older surfaces. */ discardMany?: (paths: string[]) => Promise - /** Discard a single path (restore working-tree to HEAD, or rm if untracked). */ + /** Discard a single path (restore from the index, or remove an untracked file). */ discardOne: (path: string) => Promise /** * Called when either the pre-step (bulkUnstage) rejects OR an individual @@ -99,12 +107,9 @@ export type DiscardAllResult = { * Run the "Discard all" sequence for a given area. * * For 'staged', this first bulk-unstages the paths — without that step, - * `discardOne` (which maps to `git restore --worktree --source=HEAD`) would - * reset the working tree to HEAD but leave the index carrying the staged - * delta, producing phantom inverse "Changes" rows the user thought they just - * discarded. If the unstage fails we MUST skip the discard loop entirely for - * the same reason: a stale index with a clean worktree is a worse state than - * the one the user started in. + * restoring from the index would preserve the staged changes. If the unstage + * fails, skip discard so it cannot remove working edits while leaving that + * staged delta behind. * * Per-file `discardOne` failures are best-effort: we continue past a failed * file so a single stuck path does not block the rest of the bulk action. diff --git a/src/renderer/src/components/right-sidebar/source-control/commit/discard-confirmation.ts b/src/renderer/src/components/right-sidebar/source-control/commit/discard-confirmation.ts index 1a6121b535b..280bb673e86 100644 --- a/src/renderer/src/components/right-sidebar/source-control/commit/discard-confirmation.ts +++ b/src/renderer/src/components/right-sidebar/source-control/commit/discard-confirmation.ts @@ -10,11 +10,10 @@ export type DiscardConfirmationCopy = { } /** - * Untracked and newly-added paths have no HEAD version to restore, so Orca's discard removes the - * working-tree file. Every surface that names the operation must say "delete" for these. + * Discard removes untracked files; staged additions retain their index version. */ export function discardDeletesEntryFile(entry: Pick): boolean { - return entry.area === 'untracked' || entry.status === 'untracked' || entry.status === 'added' + return entry.area === 'untracked' || entry.status === 'untracked' } export function getDiscardEntryConfirmationCopy( @@ -45,8 +44,8 @@ export function getDiscardEntryConfirmationCopy( { value0: name } ), description: translate( - 'auto.components.right.sidebar.source.control.discard.confirmation.40e9357b2a', - 'This will restore the file from HEAD and discard the deletion. This cannot be undone.' + 'sourceControl.discard.restoreStagedVersionDescription', + 'This will restore the last staged version and discard the deletion. This cannot be undone.' ), confirmLabel: 'Restore' } @@ -59,8 +58,8 @@ export function getDiscardEntryConfirmationCopy( { value0: name } ), description: translate( - 'auto.components.right.sidebar.source.control.discard.confirmation.1426c2efff', - 'This will revert all changes to this file. This cannot be undone.' + 'sourceControl.discard.unstagedChangesDescription', + 'This will revert the unstaged changes to this file. This cannot be undone.' ), confirmLabel: 'Discard' } diff --git a/src/renderer/src/components/right-sidebar/source-control/commit/rename-mutation-paths.test.ts b/src/renderer/src/components/right-sidebar/source-control/commit/rename-mutation-paths.test.ts new file mode 100644 index 00000000000..c6b2f54a570 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control/commit/rename-mutation-paths.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import type { GitStatusEntry } from '../../../../../../shared/git-status-types' +import { getDiscardAllPaths, getUnstageAllPaths } from './discard-all-sequence' + +const rename: GitStatusEntry = { + path: 'new/file.txt', + oldPath: 'old/file.txt', + status: 'renamed', + area: 'staged' +} + +describe('rename mutation selection', () => { + it('includes the old deletion for selection, directory and section unstaging', () => { + expect(getUnstageAllPaths([rename])).toEqual(['new/file.txt', 'old/file.txt']) + }) + + it('unstages and restores both paths when discarding staged renames', () => { + expect(getDiscardAllPaths([rename], 'staged')).toEqual(['new/file.txt', 'old/file.txt']) + }) + + it('leaves a copy source outside the selected mutation', () => { + const copy = { ...rename, status: 'copied' as const } + expect(getUnstageAllPaths([copy])).toEqual(['new/file.txt']) + expect(getDiscardAllPaths([copy], 'staged')).toEqual(['new/file.txt']) + }) + + it('does not reset submodule-internal entries in the parent index', () => { + expect(getUnstageAllPaths([{ ...rename, submoduleRoot: 'new' }])).toEqual([]) + }) + + it('does not add a stale old path to an unstaged discard', () => { + expect(getDiscardAllPaths([{ ...rename, area: 'unstaged' }], 'unstaged')).toEqual([ + 'new/file.txt' + ]) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/source-control/commit/source-control-entry-mutation-failures.test.tsx b/src/renderer/src/components/right-sidebar/source-control/commit/source-control-entry-mutation-failures.test.tsx index 94a78b4a008..4381102ee04 100644 --- a/src/renderer/src/components/right-sidebar/source-control/commit/source-control-entry-mutation-failures.test.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/commit/source-control-entry-mutation-failures.test.tsx @@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => ({ toastDismiss: vi.fn<(id: string) => void>(), stagePath: vi.fn(), unstagePath: vi.fn(), + bulkUnstagePaths: vi.fn(), discardPath: vi.fn() })) @@ -26,7 +27,7 @@ vi.mock('@/runtime/runtime-git-client', () => ({ unstageRuntimeGitPath: (...args: unknown[]) => mocks.unstagePath(...args), discardRuntimeGitPath: (...args: unknown[]) => mocks.discardPath(...args), bulkDiscardRuntimeGitPaths: vi.fn(), - bulkUnstageRuntimeGitPaths: vi.fn() + bulkUnstageRuntimeGitPaths: (...args: unknown[]) => mocks.bulkUnstagePaths(...args) })) vi.mock('@/store', () => ({ useAppStore: Object.assign(() => undefined, { @@ -154,6 +155,25 @@ describe('source-control entry mutation failures', () => { expect(lastToast().title).toBe('Failed to unstage “src/app.ts”') }) + it('unstages both rename paths in one request and retains them on retry', async () => { + mocks.bulkUnstagePaths.mockRejectedValueOnce(new Error('index.lock exists')) + mocks.bulkUnstagePaths.mockResolvedValueOnce(undefined) + const { result } = renderMutations() + + await act(async () => { + await result.current.handleUnstage('src/new.ts', 'src/old.ts') + }) + await act(async () => { + clickRetry() + }) + + expect(mocks.unstagePath).not.toHaveBeenCalled() + expect(mocks.bulkUnstagePaths).toHaveBeenCalledTimes(2) + for (const call of mocks.bulkUnstagePaths.mock.calls) { + expect(call[1]).toEqual(['src/new.ts', 'src/old.ts']) + } + }) + it('leaves a successful stage silent, and clears a stale failure it supersedes', async () => { mocks.stagePath.mockRejectedValueOnce(new Error('index.lock exists')) mocks.stagePath.mockResolvedValueOnce(undefined) diff --git a/src/renderer/src/components/right-sidebar/source-control/commit/use-bulk-actions.ts b/src/renderer/src/components/right-sidebar/source-control/commit/use-bulk-actions.ts index bc39e119b54..a504c662d2b 100644 --- a/src/renderer/src/components/right-sidebar/source-control/commit/use-bulk-actions.ts +++ b/src/renderer/src/components/right-sidebar/source-control/commit/use-bulk-actions.ts @@ -8,7 +8,11 @@ import { bulkUnstageRuntimeGitPaths, type RuntimeGitContext } from '@/runtime/runtime-git-client' -import { getStageAllPaths, isStageableStatusEntry } from './discard-all-sequence' +import { + getStageAllPaths, + getUnstageAllPaths, + isStageableStatusEntry +} from './discard-all-sequence' import type { SourceControlEntryGroups } from '../listing/section-order' import type { FlatEntry } from '../listing/use-selection' @@ -67,11 +71,7 @@ export function useSourceControlBulkActions({ ) const bulkUnstagePaths = useMemo( - () => - selectedEntries - // Why: submodule-internal rows are read-only from the parent worktree. - .filter((entry) => entry.area === 'staged' && !entry.entry.submoduleRoot) - .map((entry) => entry.entry.path), + () => getUnstageAllPaths(selectedEntries.map((entry) => entry.entry)), [selectedEntries] ) diff --git a/src/renderer/src/components/right-sidebar/source-control/commit/use-entry-mutations.ts b/src/renderer/src/components/right-sidebar/source-control/commit/use-entry-mutations.ts index 415601c4488..4f3cd8375fb 100644 --- a/src/renderer/src/components/right-sidebar/source-control/commit/use-entry-mutations.ts +++ b/src/renderer/src/components/right-sidebar/source-control/commit/use-entry-mutations.ts @@ -7,6 +7,7 @@ import { getConnectionId } from '@/lib/connection-context' import { basename } from '@/lib/path' import { bulkDiscardRuntimeGitPaths, + bulkUnstageRuntimeGitPaths, discardRuntimeGitPath, stageRuntimeGitPath, unstageRuntimeGitPath, @@ -80,8 +81,14 @@ export function useSourceControlEntryMutations({ ) const handleUnstage = useCallback( - (filePath: string): Promise => - runEntryMutation('unstage', filePath, unstageRuntimeGitPath), + (filePath: string, oldPath?: string): Promise => + runEntryMutation( + 'unstage', + filePath, + oldPath + ? (context, path) => bulkUnstageRuntimeGitPaths(context, [path, oldPath]) + : unstageRuntimeGitPath + ), [runEntryMutation] ) diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/section-file-list.tsx b/src/renderer/src/components/right-sidebar/source-control/listing/section-file-list.tsx index c4efe9091ee..2d71a640259 100644 --- a/src/renderer/src/components/right-sidebar/source-control/listing/section-file-list.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/listing/section-file-list.tsx @@ -66,7 +66,7 @@ export function SourceControlSectionFileList({ activeConnectionId: string | null handleOpenDiff: (entry: GitStatusEntry, event?: SourceControlRowOpenEvent) => void handleStage: (path: string) => Promise - handleUnstage: (path: string) => Promise + handleUnstage: (path: string, oldPath?: string) => Promise requestDiscardEntry: (entry: GitStatusEntry) => void diffCommentCountByPath: Map }): React.JSX.Element { diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/uncommitted-entry-row.tsx b/src/renderer/src/components/right-sidebar/source-control/listing/uncommitted-entry-row.tsx index f2c2162b812..73b9d1abd6b 100644 --- a/src/renderer/src/components/right-sidebar/source-control/listing/uncommitted-entry-row.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/listing/uncommitted-entry-row.tsx @@ -64,7 +64,7 @@ export const UncommittedEntryRow = React.memo(function UncommittedEntryRow({ connectionId?: string | null onOpen: (entry: GitStatusEntry, event?: SourceControlRowOpenEvent) => void onStage: (filePath: string) => Promise - onUnstage: (filePath: string) => Promise + onUnstage: (filePath: string, oldPath?: string) => Promise onDiscard: (entry: GitStatusEntry) => void commentCount: number showPathHint?: boolean @@ -249,7 +249,7 @@ export const UncommittedEntryRow = React.memo(function UncommittedEntryRow({ title={translate('auto.components.right.sidebar.SourceControl.df5040e3c3', 'Unstage')} onClick={(event) => { event.stopPropagation() - void onUnstage(entry.path) + void onUnstage(entry.path, entry.status === 'renamed' ? entry.oldPath : undefined) }} /> )} diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/uncommitted-sections.tsx b/src/renderer/src/components/right-sidebar/source-control/listing/uncommitted-sections.tsx index 6cc44cb95fc..7057df30286 100644 --- a/src/renderer/src/components/right-sidebar/source-control/listing/uncommitted-sections.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/listing/uncommitted-sections.tsx @@ -82,7 +82,7 @@ export function SourceControlUncommittedSections(props: { activeConnectionId: string | null handleOpenDiff: (entry: GitStatusEntry, event?: SourceControlRowOpenEvent) => void handleStage: (path: string) => Promise - handleUnstage: (path: string) => Promise + handleUnstage: (path: string, oldPath?: string) => Promise requestDiscardEntry: (entry: GitStatusEntry) => void diffCommentCountByPath: Map }): React.JSX.Element { diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 70418c4fef9..a590df4d3f7 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -1026,6 +1026,10 @@ } }, "discard": { + "confirmation": { + "1426c2efff": "This will revert all changes to this file. This cannot be undone.", + "40e9357b2a": "This will restore the file from HEAD and discard the deletion. This cannot be undone." + }, "dialog": { "48c5ef95d9": "area", "6de99d162b": "entry" @@ -2803,16 +2807,13 @@ } }, "status": { + "failedAfter": "Failed after {{value0}}", + "interruptedAfter": "Interrupted after {{value0}}", "responding": "Agent is responding", "toggleDetails": "Toggle turn details", "workedFor": "Worked for {{value0}}", - "interruptedAfter": "Interrupted after {{value0}}", - "failedAfter": "Failed after {{value0}}", "workingFor": "Working for {{value0}}" }, - "subagents": { - "unnamed": "Subagent" - }, "tool": { "countN": "{{value0}} tool calls", "countOne": "1 tool call", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 3a3d41b2f41..e310234e292 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18517,6 +18517,10 @@ } }, "sourceControl": { + "discard": { + "restoreStagedVersionDescription": "This will restore the last staged version and discard the deletion. This cannot be undone.", + "unstagedChangesDescription": "This will revert the unstaged changes to this file. This cannot be undone." + }, "unlinkedPr": { "status": "PR #{{number}} unlinked" } diff --git a/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt index b5560a84cae..c0a268d1680 100644 --- a/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt +++ b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt @@ -163,8 +163,6 @@ src/relay/external-automations-handler.ts src/relay/fs-handler-git-fallback.ts src/relay/fs-handler-list-files.ts src/relay/fs-list-files-fallback-chain.ts -src/relay/git-handler.ts -src/relay/git-stdout-stream.ts src/relay/preflight-handler.ts src/relay/pty-shell-utils.ts src/relay/subprocess-tree-termination.ts diff --git a/src/shared/child-process/__fixtures__/windows-console-visibility-allowlist.txt b/src/shared/child-process/__fixtures__/windows-console-visibility-allowlist.txt index d59e4e40e8d..d94df517b43 100644 --- a/src/shared/child-process/__fixtures__/windows-console-visibility-allowlist.txt +++ b/src/shared/child-process/__fixtures__/windows-console-visibility-allowlist.txt @@ -53,7 +53,6 @@ relay/agent-exec-handler.ts relay/external-automations-handler.ts relay/fs-handler-git-fallback.ts relay/fs-list-files-fallback-chain.ts -relay/git-handler.ts relay/pty-shell-utils.ts relay/subprocess-tree-termination.ts relay/workspace-space-scan.ts diff --git a/src/shared/child-process/bounded-output-sink.test.ts b/src/shared/child-process/bounded-output-sink.test.ts index c94458e7211..4e17bb9ec9d 100644 --- a/src/shared/child-process/bounded-output-sink.test.ts +++ b/src/shared/child-process/bounded-output-sink.test.ts @@ -31,4 +31,36 @@ describe('bounded process output', () => { expect(sink.text()).toBe('a�') expect(sink.truncated()).toBe(true) }) + + it('keeps a bounded tail across reads, string chunks, and oversized buffers', () => { + const sink = createOutputSink(4, 'tail') + expect(sink.buffer()).toEqual(Buffer.alloc(0)) + sink.write('abc') + expect(sink.text()).toBe('abc') + sink.write('def') + const snapshot = sink.buffer() + expect(snapshot).toEqual(Buffer.from('cdef')) + snapshot.fill(0) + expect(sink.text()).toBe('cdef') + const oversized = Buffer.concat([Buffer.alloc(12 * 1024 * 1024), Buffer.from('tail')]) + sink.write(oversized) + oversized.fill(0) + expect(sink.buffer()).toEqual(Buffer.from('tail')) + expect(sink.buffer().buffer.byteLength).toBeLessThanOrEqual(Buffer.poolSize) + expect(sink.truncated()).toBe(true) + }) + + it('retains exactly the newest UTF-8 bytes across every chunk boundary and cap', () => { + const bytes = Buffer.from('a💻é\r\nb') + for (let split = 0; split <= bytes.length; split += 1) { + for (let cap = 0; cap <= bytes.length + 1; cap += 1) { + const sink = createOutputSink(cap, 'tail') + sink.write(bytes.subarray(0, split)) + sink.write(bytes.subarray(split)) + expect(sink.buffer()).toEqual(bytes.subarray(Math.max(0, bytes.length - cap))) + expect(sink.text()).toBe(bytes.subarray(Math.max(0, bytes.length - cap)).toString('utf8')) + expect(sink.truncated()).toBe(bytes.length > cap) + } + } + }) }) diff --git a/src/shared/child-process/bounded-output-sink.ts b/src/shared/child-process/bounded-output-sink.ts index 8e1a9309978..4c02b4651cc 100644 --- a/src/shared/child-process/bounded-output-sink.ts +++ b/src/shared/child-process/bounded-output-sink.ts @@ -1,4 +1,5 @@ import { Buffer } from 'node:buffer' +import { GrowingByteBuffer } from '../growing-byte-buffer' /** * Collects output up to a cap, so a chatty child cannot grow the heap. @@ -7,28 +8,42 @@ import { Buffer } from 'node:buffer' * emits strings, and concatenating those as buffers throws inside a `data` * handler, where the rejection has nowhere to go and the caller just hangs. */ -export function createOutputSink(maxBytes: number): { +export function createOutputSink( + maxBytes: number, + outputCapture: 'head' | 'tail' = 'head' +): { write: (chunk: Buffer | string) => void + buffer: () => Buffer text: () => string truncated: () => boolean } { const chunks: Buffer[] = [] + const tail = outputCapture === 'tail' ? new GrowingByteBuffer() : undefined let bytes = 0 + const buffer = (): Buffer => + tail + ? tail.toBuffer() + : chunks.length === 0 + ? Buffer.alloc(0) + : chunks.length === 1 + ? chunks[0] + : Buffer.concat(chunks) return { + buffer, write(raw) { const chunk = Buffer.isBuffer(raw) ? raw : Buffer.from(raw) const remaining = maxBytes - bytes + bytes += chunk.length + if (tail) { + tail.appendRetainedSuffix(chunk, maxBytes) + return + } if (remaining <= 0) { - bytes += chunk.length return } chunks.push(chunk.length > remaining ? chunk.subarray(0, remaining) : chunk) - bytes += chunk.length }, - text: () => - chunks.length === 0 - ? '' - : (chunks.length === 1 ? chunks[0] : Buffer.concat(chunks)).toString('utf8'), + text: () => tail?.toString() ?? buffer().toString('utf8'), // Why: callers that parse the output need to tell a short answer from a // clipped one -- truncated JSON or JSONL parses as a smaller valid result. truncated: () => bytes > maxBytes diff --git a/src/shared/child-process/process-spec.ts b/src/shared/child-process/process-spec.ts index 2acfe82d61d..2b5b7765816 100644 --- a/src/shared/child-process/process-spec.ts +++ b/src/shared/child-process/process-spec.ts @@ -38,6 +38,10 @@ export type ProcessSpec = { input?: string /** Cap on captured stdout/stderr; output past it is discarded. */ maxOutputBytes?: number + /** Capture stdout as bytes without decoding; stdout stays empty in this mode. */ + captureStdoutAsBytes?: boolean + /** Stop a parser command as soon as captured output exceeds its cap. */ + killOnOutputLimit?: boolean /** Kills the process when aborted; the result still reports the exit. */ signal?: AbortSignal /** Keep the child in its own POSIX process group for tree termination. */ @@ -62,6 +66,7 @@ export type ProcessResult = { code: number | null signal: NodeJS.Signals | null stdout: string + stdoutBytes?: Buffer stderr: string /** True when the process was killed by `timeoutMs` rather than exiting. */ timedOut: boolean diff --git a/src/shared/child-process/run-process-bytes.test.ts b/src/shared/child-process/run-process-bytes.test.ts new file mode 100644 index 00000000000..d556e42ff53 --- /dev/null +++ b/src/shared/child-process/run-process-bytes.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import { runProcess, runProcessSync } from './run-process' + +describe('process byte capture', () => { + it('preserves invalid UTF-8 and NUL bytes in async and sync capture', async () => { + const spec = { + program: process.execPath, + args: ['-e', 'process.stdout.write(Buffer.from([0,255,254,128,65]))'], + captureStdoutAsBytes: true + } + for (const result of [await runProcess(spec), runProcessSync(spec)]) { + expect(result).toMatchObject({ code: 0, stdout: '', outputTruncated: false }) + expect(result.stdoutBytes).toEqual(Buffer.from([0, 255, 254, 128, 65])) + } + }) + + it('stops an overflowing producer before its normal timeout', async () => { + const result = await runProcess({ + program: process.execPath, + args: ['-e', 'process.stdout.write(Buffer.alloc(1000)); setInterval(() => {}, 1000)'], + captureStdoutAsBytes: true, + killOnOutputLimit: true, + maxOutputBytes: 50, + terminationBarrier: true, + timeoutMs: 10_000 + }) + expect(result).toMatchObject({ outputTruncated: true, timedOut: false, stdout: '' }) + expect(result.stdoutBytes).toEqual(Buffer.alloc(50)) + expect(result.code === 0 && result.signal === null).toBe(false) + }) + + it('lets diagnostic streams exceed the cap while retaining their binary tails', async () => { + const result = await runProcess( + { + program: process.execPath, + args: [ + '-e', + 'process.stdout.write(Buffer.alloc(12*1024*1024));process.stdout.write(Buffer.from([0,255,254,128]));process.stderr.write(Buffer.alloc(12*1024*1024));process.stderr.write("done")' + ], + maxOutputBytes: 4, + captureStdoutAsBytes: true, + terminationBarrier: true, + timeoutMs: 10_000 + }, + 'tail' + ) + expect(result).toMatchObject({ + code: 0, + signal: null, + timedOut: false, + outputTruncated: true, + stdout: '', + stderr: 'done' + }) + expect(result.stdoutBytes).toEqual(Buffer.from([0, 255, 254, 128])) + }) +}) diff --git a/src/shared/child-process/run-process.ts b/src/shared/child-process/run-process.ts index ec0a3026cc5..63265af47e5 100644 --- a/src/shared/child-process/run-process.ts +++ b/src/shared/child-process/run-process.ts @@ -71,8 +71,12 @@ export function spawnProcess(spec: ProcessSpec): ChildProcessWithoutNullStreams * * Never rejects on a non-zero exit — the exit code is data. Rejects only when * the process could not be started at all. + * Tail capture keeps final diagnostics without changing termination policy. */ -export function runProcess(spec: ProcessSpec): Promise { +export function runProcess( + spec: ProcessSpec, + outputCapture: 'head' | 'tail' = 'head' +): Promise { if (spec.signal?.aborted) { spec.onChildTerminated?.() return Promise.resolve({ code: null, signal: null, stdout: '', stderr: '', timedOut: false }) @@ -90,8 +94,8 @@ export function runProcess(spec: ProcessSpec): Promise { return } - const stdout = createOutputSink(maxOutputBytes) - const stderr = createOutputSink(maxOutputBytes) + const stdout = createOutputSink(maxOutputBytes, outputCapture) + const stderr = createOutputSink(maxOutputBytes, outputCapture) let timedOut = false let settled = false let barrierStopping = false @@ -115,9 +119,17 @@ export function runProcess(spec: ProcessSpec): Promise { act() } - child.stdout?.on('data', (chunk: Buffer | string) => stdout.write(chunk)) + child.stdout?.on('data', (chunk: Buffer | string) => { + stdout.write(chunk) + if (spec.killOnOutputLimit && stdout.truncated()) { + stopAndSettle() + } + }) child.stderr?.on('data', (chunk: Buffer | string) => { stderr.write(chunk) + if (spec.killOnOutputLimit && stderr.truncated()) { + stopAndSettle() + } if (typeof spec.terminationBarrier === 'object') { spec.terminationBarrier.observeStderr?.(chunk) } @@ -150,7 +162,8 @@ export function runProcess(spec: ProcessSpec): Promise { resolve({ code, signal, - stdout: stdout.text(), + stdout: spec.captureStdoutAsBytes ? '' : stdout.text(), + ...(spec.captureStdoutAsBytes ? { stdoutBytes: stdout.buffer() } : {}), stderr: stderr.text(), timedOut, outputTruncated: stdout.truncated() || stderr.truncated() @@ -347,7 +360,8 @@ export function runProcessSync(spec: ProcessSpec): ProcessResult { return { code: result.status, signal: result.signal, - stdout: result.stdout?.toString('utf8') ?? '', + stdout: spec.captureStdoutAsBytes ? '' : (result.stdout?.toString('utf8') ?? ''), + ...(spec.captureStdoutAsBytes ? { stdoutBytes: result.stdout ?? Buffer.alloc(0) } : {}), stderr: result.stderr?.toString('utf8') ?? '', // Why always false: spawnSync reports an overrun as an ENOBUFS error, and // the guard above rethrows it, so no truncated result reaches this point. diff --git a/src/shared/child-process/windows-console-visibility.test.ts b/src/shared/child-process/windows-console-visibility.test.ts index bc3e4b60b4b..128d790d5ce 100644 --- a/src/shared/child-process/windows-console-visibility.test.ts +++ b/src/shared/child-process/windows-console-visibility.test.ts @@ -34,7 +34,7 @@ const ALLOWLIST: readonly string[] = readAllowlist( * the allowlist does not bound this: a swap (one file fixed and delisted, one * new file added with its entry) satisfies both membership assertions. */ -const UNHIDDEN_SPAWNER_PIN = 60 +const UNHIDDEN_SPAWNER_PIN = 59 const CHILD_PROCESS_IMPORT = /from\s+['"](?:node:)?child_process['"]|require\(\s*['"](?:node:)?child_process['"]/ diff --git a/src/main/git/command-runner/git-admission-candidate-heap.ts b/src/shared/git-admission-candidate-heap.ts similarity index 100% rename from src/main/git/command-runner/git-admission-candidate-heap.ts rename to src/shared/git-admission-candidate-heap.ts diff --git a/src/shared/git-admission-node-compatibility.test.ts b/src/shared/git-admission-node-compatibility.test.ts new file mode 100644 index 00000000000..ecbfdbfadc3 --- /dev/null +++ b/src/shared/git-admission-node-compatibility.test.ts @@ -0,0 +1,25 @@ +import { afterEach, expect, it, vi } from 'vitest' + +afterEach(() => { + vi.doUnmock('node:os') + vi.resetModules() +}) + +it.each([ + [undefined, 2], + [() => 6, 2], + [() => 32, 4] +] as const)( + 'loads the scheduler with parallelism API %s and capacity %i', + async (api, capacity) => { + vi.resetModules() + vi.doMock('node:os', () => ({ availableParallelism: api })) + const { GENERAL_CAP, GitAdmissionScheduler } = await import('./git-admission-scheduler.js') + expect(GENERAL_CAP).toBe(capacity) + const scheduler = new GitAdmissionScheduler() + const grant = await scheduler.acquire({ args: ['status'], cwd: '/repo' }) + expect(scheduler.snapshot().budgets.general.baseUsed).toBe(1) + grant.release() + expect(scheduler.snapshot().budgets.general.baseUsed).toBe(0) + } +) diff --git a/src/shared/git-admission-scheduler.ts b/src/shared/git-admission-scheduler.ts new file mode 100644 index 00000000000..58ad38b2426 --- /dev/null +++ b/src/shared/git-admission-scheduler.ts @@ -0,0 +1,303 @@ +import { uncRouteKey } from './wsl-paths' +import { classifyGitCommand } from './git-command-classification' +import { GitAdmissionWaiterQueue } from './git-admission-waiter-queue' +import { + ADMISSION_TIER_VALUE, + AdmissionEventPublisher, + DEFAULT_ADMISSION_SCHEDULER_CONFIG, + type AdmissionBudget, + type AdmissionClass, + type AdmissionSchedulerConfig, + type AdmissionSlotKind, + type AdmissionWaiter, + type GitAdmissionGrant, + type GitAdmissionRequest +} from './git-admission-state' + +export type { + GitAdmissionEvent, + GitAdmissionGrant, + GitAdmissionRequest +} from './git-admission-state' +export { + GENERAL_CAP, + GENERAL_HEADROOM, + GIT_ADMISSION_AGING_MS, + MAX_GIT_CHILDREN, + NETWORK_CAP, + NETWORK_HEADROOM, + ROUTE_CAP, + ROUTE_HEADROOM +} from './git-admission-state' + +function routeKey(request: GitAdmissionRequest): string | null { + const distro = request.wslDistro?.trim().toLowerCase() + return distro ? `wsl:${distro}` : uncRouteKey(request.cwd) +} + +export class GitAdmissionScheduler { + private readonly config: AdmissionSchedulerConfig + private readonly budgets = new Map() + private readonly waiters = new GitAdmissionWaiterQueue() + private nextWaiterId = 0 + private readonly eventPublisher: AdmissionEventPublisher + + constructor(config: Partial = {}) { + this.config = { ...DEFAULT_ADMISSION_SCHEDULER_CONFIG, ...config } + this.eventPublisher = new AdmissionEventPublisher(this.config.onAdmissionEvent) + } + + acquire(request: GitAdmissionRequest): Promise { + if (request.signal?.aborted) { + return Promise.reject(new DOMException('The operation was aborted.', 'AbortError')) + } + const enqueuedAt = this.config.now() + const { admissionClass, route, budgetKeys } = this.resolveBudgets(request) + return new Promise((resolve, reject) => { + const waiter: AdmissionWaiter = { + id: this.nextWaiterId++, + args: request.args, + tier: request.tier ?? 'status', + admissionClass, + route, + enqueuedAt, + budgetKeys, + signal: request.signal, + state: 'queued', + resolve, + reject, + onAbort: () => this.abort(waiter) + } + this.waiters.enqueue(waiter) + this.refreshRouteEligibility(admissionClass, route) + request.signal?.addEventListener('abort', waiter.onAbort, { once: true }) + if (request.signal?.aborted) { + this.abort(waiter) + return + } + // Adding a blocked waiter cannot make an older waiter runnable. Avoid a + // queue scan for every arrival while the fixed-size budget is saturated. + if (this.slotKindFor(waiter)) { + this.drain(admissionClass) + } + }) + } + + snapshot(): { + queued: number + queuedWaiters: { id: number; args: readonly string[]; tier: AdmissionWaiter['tier'] }[] + budgets: Record + candidateCount: number + } { + const queuedWaiters = this.waiters.snapshot() + return { + queued: this.waiters.count, + queuedWaiters: queuedWaiters.map(({ id, args, tier }) => ({ id, args, tier })), + candidateCount: this.waiters.candidateCountForTests, + budgets: Object.fromEntries( + [...this.budgets].map(([key, budget]) => [ + key, + { baseUsed: budget.baseUsed, headroomUsed: budget.headroomUsed } + ]) + ) + } + } + + private resolveBudgets(request: GitAdmissionRequest): { + admissionClass: AdmissionClass + route: string | null + budgetKeys: readonly string[] + } { + const admissionClass = classifyGitCommand(request.args) === 'network' ? 'network' : 'general' + const route = routeKey(request) + const keys: string[] = [admissionClass] + if (route) { + keys.push(`route:${admissionClass}:${route}`) + } + for (const key of keys) { + this.ensureBudget(key) + } + return { admissionClass, route, budgetKeys: keys } + } + + private ensureBudget(key: string): AdmissionBudget { + let budget = this.budgets.get(key) + if (budget) { + return budget + } + const isRoute = key.startsWith('route:') + const isNetwork = key === 'network' + budget = { + baseCapacity: isRoute + ? this.config.routeCap + : isNetwork + ? this.config.networkCap + : this.config.generalCap, + headroomCapacity: isRoute + ? this.config.routeHeadroom + : isNetwork + ? this.config.networkHeadroom + : this.config.generalHeadroom, + baseUsed: 0, + headroomUsed: 0 + } + this.budgets.set(key, budget) + return budget + } + + private effectiveTier(waiter: AdmissionWaiter, now: number): number { + const promotions = Math.floor((now - waiter.enqueuedAt) / this.config.agingMs) + return Math.max(0, ADMISSION_TIER_VALUE[waiter.tier] - promotions) + } + + private fits(waiter: AdmissionWaiter, slotKind: AdmissionSlotKind): boolean { + return waiter.budgetKeys.every((key) => { + const budget = this.ensureBudget(key) + return slotKind === 'base' + ? budget.baseUsed < budget.baseCapacity + : budget.headroomUsed < budget.headroomCapacity + }) + } + + private slotKindFor(waiter: AdmissionWaiter): AdmissionSlotKind | null { + return this.fits(waiter, 'base') + ? 'base' + : waiter.tier === 'interactive' && this.fits(waiter, 'headroom') + ? 'headroom' + : null + } + + private drain(admissionClass: AdmissionClass): void { + while (true) { + const now = this.config.now() + const globalBudget = this.ensureBudget(admissionClass) + const selected = this.waiters.nextFitting( + admissionClass, + (waiter) => this.effectiveTier(waiter, now), + globalBudget.baseUsed < globalBudget.baseCapacity, + globalBudget.headroomUsed < globalBudget.headroomCapacity, + (waiter) => this.abort(waiter) + ) + if (!selected) { + return + } + this.grant(selected.waiter, selected.slotKind, now) + } + } + + private grant(waiter: AdmissionWaiter, slotKind: AdmissionSlotKind, now: number): void { + waiter.state = 'granted' + waiter.slotKind = slotKind + for (const key of waiter.budgetKeys) { + const budget = this.ensureBudget(key) + if (slotKind === 'base') { + budget.baseUsed += 1 + } else { + budget.headroomUsed += 1 + } + } + this.refreshRouteEligibility(waiter.admissionClass, waiter.route) + this.waiters.dequeue(waiter) + const queueWaitMs = Math.max(0, now - waiter.enqueuedAt) + this.publishEvent(waiter, slotKind, 'grant', queueWaitMs) + queueMicrotask(() => { + if (waiter.state !== 'granted') { + return + } + waiter.state = 'settled' + waiter.signal?.removeEventListener('abort', waiter.onAbort) + waiter.resolve({ + queueWaitMs, + release: this.releaseOnce(waiter, slotKind, queueWaitMs) + }) + }) + } + + private releaseOnce( + waiter: AdmissionWaiter, + slotKind: AdmissionSlotKind, + queueWaitMs: number + ): () => void { + let released = false + return () => { + if (released) { + return + } + released = true + for (const key of waiter.budgetKeys) { + const budget = this.ensureBudget(key) + if (slotKind === 'base') { + budget.baseUsed -= 1 + } else { + budget.headroomUsed -= 1 + } + } + this.refreshRouteEligibility(waiter.admissionClass, waiter.route) + this.publishEvent(waiter, slotKind, 'release', queueWaitMs) + this.pruneRouteBudgets(waiter.budgetKeys) + this.drain(waiter.admissionClass) + } + } + + private abort(waiter: AdmissionWaiter): void { + if (waiter.state === 'settled') { + return + } + if (waiter.state === 'granted' && waiter.slotKind) { + this.releaseOnce( + waiter, + waiter.slotKind, + Math.max(0, this.config.now() - waiter.enqueuedAt) + )() + } + const wasQueued = waiter.state === 'queued' + waiter.state = 'settled' + waiter.signal?.removeEventListener('abort', waiter.onAbort) + if (wasQueued) { + this.waiters.dequeue(waiter) + this.pruneRouteBudgets(waiter.budgetKeys) + } + waiter.reject(new DOMException('The operation was aborted.', 'AbortError')) + } + + private publishEvent( + waiter: AdmissionWaiter, + slotKind: AdmissionSlotKind, + phase: 'grant' | 'release', + queueWaitMs: number + ): void { + this.eventPublisher.publish({ + phase, + waiter, + slotKind, + queueWaitMs, + queued: this.waiters.count, + budgets: this.budgets + }) + } + + private pruneRouteBudgets(keys: readonly string[]): void { + for (const key of keys) { + const budget = this.budgets.get(key) + if ( + budget && + key.startsWith('route:') && + budget.baseUsed === 0 && + budget.headroomUsed === 0 && + !this.waiters.hasBudget(key) + ) { + this.budgets.delete(key) + } + } + } + + private refreshRouteEligibility(admissionClass: AdmissionClass, route: string | null): void { + const budget = route ? this.ensureBudget(`route:${admissionClass}:${route}`) : null + this.waiters.updateRouteEligibility( + admissionClass, + route, + !budget || budget.baseUsed < budget.baseCapacity, + !budget || budget.headroomUsed < budget.headroomCapacity + ) + } +} diff --git a/src/main/git/command-runner/git-admission-state.ts b/src/shared/git-admission-state.ts similarity index 90% rename from src/main/git/command-runner/git-admission-state.ts rename to src/shared/git-admission-state.ts index a9a9ce78ddd..ece1c22621e 100644 --- a/src/main/git/command-runner/git-admission-state.ts +++ b/src/shared/git-admission-state.ts @@ -1,7 +1,9 @@ -import { availableParallelism } from 'node:os' -import type { GitAdmissionTier } from './git-exec-options' +import * as os from 'node:os' +import type { GitAdmissionTier } from './rpc-contract/git-admission-tier-params' -export const GENERAL_CAP = Math.max(2, Math.min(4, availableParallelism() - 4)) +// Older relay hosts lack availableParallelism; keep their concurrency conservative. +const parallelism = typeof os.availableParallelism === 'function' ? os.availableParallelism() : 1 +export const GENERAL_CAP = Math.max(2, Math.min(4, parallelism - 4)) export const NETWORK_CAP = 3 export const GENERAL_HEADROOM = 2 export const NETWORK_HEADROOM = 1 diff --git a/src/main/git/command-runner/git-admission-waiter-queue.ts b/src/shared/git-admission-waiter-queue.ts similarity index 98% rename from src/main/git/command-runner/git-admission-waiter-queue.ts rename to src/shared/git-admission-waiter-queue.ts index 1f0e9a55cde..3edfa14ac94 100644 --- a/src/main/git/command-runner/git-admission-waiter-queue.ts +++ b/src/shared/git-admission-waiter-queue.ts @@ -1,6 +1,6 @@ import type { AdmissionClass, AdmissionSlotKind, AdmissionWaiter } from './git-admission-state' import { CandidateHeap, type Candidate, type WaiterLane } from './git-admission-candidate-heap' -import type { GitAdmissionTier } from './git-exec-options' +import type { GitAdmissionTier } from './rpc-contract/git-admission-tier-params' type SelectedWaiter = { waiter: AdmissionWaiter diff --git a/src/shared/git-binary-compatibility.test.ts b/src/shared/git-binary-compatibility.test.ts index b8c09122cf8..2e251d73d85 100644 --- a/src/shared/git-binary-compatibility.test.ts +++ b/src/shared/git-binary-compatibility.test.ts @@ -1,5 +1,5 @@ import { execFile } from 'node:child_process' -import { mkdir, mkdtemp, readFile, rm, unlink, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, rm, unlink, utimes, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { promisify } from 'node:util' @@ -27,6 +27,10 @@ import { } from './review-head-tracking-ref' import { parseWorktreeList } from './git-worktree-porcelain-parser' import { fastForwardLocalBaseBranch } from './worktree/local-base-branch-fast-forward' +import { gitChangeListArgs, parseGitChangeList } from './git-change-list' +import { encodeGitPathspecs } from './git-pathspec-stdin' +import { endSubprocessStdin } from './subprocess-stdin-write' +import { registerGitResolutionBinaryCompatibilityCases } from './git-resolution-binary-compatibility.test-cases' const execFileAsync = promisify(execFile) const image = process.env.ORCA_GIT_COMPAT_IMAGE @@ -40,17 +44,22 @@ describeBinaryCompatibility('real Git binary compatibility', () => { let repoPath = '' let version = { major: 0, minor: 0 } - async function runGit(args: string[], env?: NodeJS.ProcessEnv): Promise { + async function runGit( + args: string[], + env?: NodeJS.ProcessEnv, + stdin?: string + ): Promise { if (image) { const dockerUser = typeof process.getuid === 'function' && typeof process.getgid === 'function' ? ['--user', `${process.getuid()}:${process.getgid()}`] : [] - return execFileAsync( + const pending = execFileAsync( 'docker', [ 'run', '--rm', + ...(stdin === undefined ? [] : ['-i']), '--network=none', ...dockerUser, ...Object.entries(env ?? {}).flatMap(([key, value]) => @@ -67,12 +76,26 @@ describeBinaryCompatibility('real Git binary compatibility', () => { ], { maxBuffer: 2 * 1024 * 1024 } ) + if (stdin !== undefined) { + endSubprocessStdin(pending.child.stdin, stdin) + } + return pending } - return execFileAsync(binary!, args, { + const pending = execFileAsync(binary!, args, { cwd: repoPath, - env: env ? { ...process.env, ...env } : undefined, + env: { + ...process.env, + HOME: repoPath, + XDG_CONFIG_HOME: repoPath, + GIT_CONFIG_NOSYSTEM: '1', + ...env + }, maxBuffer: 2 * 1024 * 1024 }) + if (stdin !== undefined) { + endSubprocessStdin(pending.child.stdin, stdin) + } + return pending } function supports(major: number, minor: number): boolean { @@ -115,6 +138,123 @@ describeBinaryCompatibility('real Git binary compatibility', () => { } }) + it('stages, unstages and restores NUL-delimited literal pathspecs from stdin', async () => { + const fixturePath = join(repoPath, 'stdin-pathspec') + await mkdir(fixturePath) + const fixtureCwd = image ? '/repo/stdin-pathspec' : fixturePath + const runFixtureGit = (args: string[], stdin?: string): Promise => + runGit(['-C', fixtureCwd, ...args], undefined, stdin) + await runFixtureGit(['init', '-q']) + await runFixtureGit(['config', 'user.name', 'Compatibility Test']) + await runFixtureGit(['config', 'user.email', 'compatibility@example.invalid']) + const paths = ['[k]eep.log', 'space name.txt', '-option.txt'] + if (process.platform !== 'win32') { + paths.push('line\nname.txt', ':(magic).txt') + } + await Promise.all(paths.map((filePath) => writeFile(join(fixturePath, filePath), 'original\n'))) + const stdin = encodeGitPathspecs(paths.map((filePath) => `:(literal)${filePath}`)) + await runFixtureGit(['add', '--pathspec-from-file=-', '--pathspec-file-nul'], stdin) + await runFixtureGit(['reset', '--quiet', '--', `:(literal)${paths[0]}`]) + expect( + (await runFixtureGit(['ls-files', '-z'])).stdout.split('\0').filter(Boolean).sort() + ).toEqual(paths.slice(1).sort()) + await runFixtureGit( + ['reset', '--quiet', '--pathspec-from-file=-', '--pathspec-file-nul'], + stdin + ) + expect((await runFixtureGit(['ls-files', '-z'])).stdout).toBe('') + await runFixtureGit(['add', '--pathspec-from-file=-', '--pathspec-file-nul'], stdin) + await runFixtureGit(['commit', '-qm', 'stdin pathspec fixtures']) + await Promise.all(paths.map((filePath) => writeFile(join(fixturePath, filePath), 'modified\n'))) + await runFixtureGit(['add', '--pathspec-from-file=-', '--pathspec-file-nul'], stdin) + const staged = await runFixtureGit(['diff', '--cached', '--name-only', '-z']) + expect(staged.stdout.split('\0').filter(Boolean).sort()).toEqual([...paths].sort()) + await Promise.all(paths.map((filePath) => writeFile(join(fixturePath, filePath), 'working\n'))) + await runFixtureGit( + ['restore', '--worktree', '--pathspec-from-file=-', '--pathspec-file-nul'], + stdin + ) + for (const filePath of paths) { + expect(await readFile(join(fixturePath, filePath), 'utf8')).toBe('modified\n') + } + await runFixtureGit( + ['reset', '--quiet', '--pathspec-from-file=-', '--pathspec-file-nul'], + stdin + ) + expect((await runFixtureGit(['diff', '--cached', '--name-only'])).stdout).toBe('') + await runFixtureGit( + ['restore', '--worktree', '--pathspec-from-file=-', '--pathspec-file-nul'], + stdin + ) + for (const filePath of paths) { + expect(await readFile(join(fixturePath, filePath), 'utf8')).toBe('original\n') + } + await rm(fixturePath, { recursive: true, force: true }) + }) + + it('emits raw changes and numstat from one range or root diff', async () => { + const head = (await runGit(['rev-parse', 'HEAD'])).stdout.trim() + const range = await runGit(gitChangeListArgs(head, head)) + expect(parseGitChangeList(range.stdout)).toEqual([]) + const root = await runGit(gitChangeListArgs(null, head)) + expect(parseGitChangeList(root.stdout)).toEqual([ + { path: 'tracked.txt', status: 'added', added: 1, removed: 0 } + ]) + }) + + it('reads signed history without launching configured signature verification', async () => { + const tree = (await runGit(['rev-parse', 'HEAD^{tree}'])).stdout.trim() + const commit = [ + `tree ${tree}`, + 'author Compatibility Test 1234567890 +0000', + 'committer Compatibility Test 1234567890 +0000', + 'gpgsig -----BEGIN PGP SIGNATURE-----', + ' ', + ' ZHVtbXk=', + ' -----END PGP SIGNATURE-----', + '', + 'signed history fixture', + '' + ].join('\n') + const oid = ( + await runGit(['hash-object', '-t', 'commit', '-w', '--stdin'], undefined, commit) + ).stdout.trim() + const result = await runGit([ + '-c', + 'log.showSignature=true', + '-c', + 'color.ui=always', + '-c', + 'gpg.program=orca-nonexistent-signature-verifier', + 'log', + '--no-show-signature', + '--no-color', + `--format=${GIT_HISTORY_COMMIT_FORMAT}`, + '-z', + '-n1', + oid + ]) + expect(result.stderr).toBe('') + expect(parseGitHistoryLog(result.stdout)).toMatchObject([ + { id: oid, subject: 'signed history fixture' } + ]) + }) + + it('keeps polling diffs from refreshing the index', async () => { + const indexPath = join(repoPath, '.git', 'index') + const before = await readFile(indexPath) + const future = new Date(Date.now() + 10_000) + await utimes(join(repoPath, 'tracked.txt'), future, future) + const result = await runGit( + ['-c', 'diff.autoRefreshIndex=false', 'diff', '--numstat', '-z', '--'], + { GIT_OPTIONAL_LOCKS: '0' } + ) + expect(['', '0\t0\ttracked.txt\0']).toContain(result.stdout) + expect(await readFile(indexPath)).toEqual(before) + await runGit(['-c', 'diff.autoRefreshIndex=true', 'diff', '--numstat', '-z', '--']) + expect(await readFile(indexPath)).not.toEqual(before) + }) + it('quietly distinguishes present and absent branch refs', async () => { const head = (await runGit(['rev-parse', 'HEAD'])).stdout.trim() await runGit(['branch', 'quiet-probe-present', head]) @@ -126,6 +266,28 @@ describeBinaryCompatibility('real Git binary compatibility', () => { ).rejects.toMatchObject({ code: 1, stdout: '', stderr: '' }) }) + it('combines repository booleans and metadata paths for normal, bare and linked repositories', async () => { + const probe = [ + 'rev-parse', + '--is-inside-work-tree', + '--is-bare-repository', + '--git-dir', + '--git-common-dir' + ] + const main = (await runGit(probe)).stdout.trim().split('\n') + expect(main).toEqual(['true', 'false', '.git', '.git']) + await runGit(['init', '--bare', '-q', 'detection-bare.git']) + const bare = (await runGit(['-C', 'detection-bare.git', ...probe])).stdout.trim().split('\n') + expect(bare).toEqual(['false', 'true', '.', '.']) + await runGit(['worktree', 'add', '-q', '-b', 'detection-linked', 'detection-linked']) + const linked = (await runGit(['-C', 'detection-linked', ...probe])).stdout.trim().split('\n') + expect(linked.slice(0, 2)).toEqual(['true', 'false']) + expect(linked[2]).not.toBe(linked[3]) + await expect(runGit(['-C', '.git', ...probe])).resolves.toMatchObject({ + stdout: expect.stringMatching(/^false\nfalse\n/) + }) + }) + it('distinguishes an absent branch from a ref pointing at a missing object', async () => { const missingObject = 'a'.repeat(40) const refPath = join(repoPath, '.git', 'refs', 'heads', 'quiet-probe-dangling') @@ -687,4 +849,7 @@ describeBinaryCompatibility('real Git binary compatibility', () => { await runGit(['worktree', 'remove', '--force', worktree]) } }) + registerGitResolutionBinaryCompatibilityCases(runGit, (name) => + image ? `/repo/${name}.git` : join(repoPath, `${name}.git`) + ) }) diff --git a/src/shared/git-blob-absence.test.ts b/src/shared/git-blob-absence.test.ts new file mode 100644 index 00000000000..13c0c9d09e3 --- /dev/null +++ b/src/shared/git-blob-absence.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { isMissingGitBlobPath } from './git-blob-absence' + +describe('Git blob path absence', () => { + it.each([ + { stderr: "fatal: path 'new.txt' exists on disk, but not in the index\n" }, + { + stderr: Buffer.from( + "fatal: Path 'new.txt' does not exist (neither on disk nor in the index).\n" + ) + } + ])('recognizes exact index absence across Git versions', ({ stderr }) => { + expect(isMissingGitBlobPath({ code: 128, stderr }, 'new.txt')).toBe(true) + }) + + it.each([ + "fatal: path 'new.txt' does not exist in 'HEAD'\n", + "fatal: Path 'new.txt' exists on disk, but not in 'HEAD'.\n" + ])('recognizes exact tree path absence', (stderr) => { + expect(isMissingGitBlobPath({ code: 128, stderr }, 'new.txt', 'HEAD')).toBe(true) + }) + + it.each([ + 'fatal: bad object :new.txt', + 'fatal: invalid object name HEAD', + 'fatal: detected dubious ownership in repository', + 'fatal: bad config line 1', + "fatal: path 'other.txt' does not exist (neither on disk nor in the index)", + "fatal: path 'new.txt' is in the index, but not at stage 0" + ])('does not classify another failure as absence (%s)', (stderr) => { + expect(isMissingGitBlobPath({ code: 128, stderr }, 'new.txt')).toBe(false) + }) + + it('requires the exit status as well as the diagnostic', () => { + const stderr = "fatal: path 'new.txt' exists on disk, but not in the index" + expect(isMissingGitBlobPath({ code: 1, stderr }, 'new.txt')).toBe(false) + expect(isMissingGitBlobPath({ code: '128', stderr }, 'new.txt')).toBe(false) + }) + + it('matches newline and quote filenames literally', () => { + const filePath = "quote' and\nnewline.txt" + const stderr = `fatal: path '${filePath}' exists on disk, but not in the index\n` + expect(isMissingGitBlobPath({ code: 128, stderr }, filePath)).toBe(true) + expect( + isMissingGitBlobPath({ code: 128, stderr: `fatal: bad object :${filePath}` }, filePath) + ).toBe(false) + }) +}) diff --git a/src/shared/git-blob-absence.ts b/src/shared/git-blob-absence.ts new file mode 100644 index 00000000000..e6c13b76d18 --- /dev/null +++ b/src/shared/git-blob-absence.ts @@ -0,0 +1,21 @@ +import { readGitCommandFailureStderr, readGitCommandFailureText } from './git-command-failure-text' + +/** A missing path diagnostic proves absence; exit 128 alone also covers corrupt objects. */ +export function isMissingGitBlobPath(error: unknown, filePath: string, oid?: string): boolean { + if (typeof error !== 'object' || error === null || !('code' in error) || error.code !== 128) { + return false + } + const text = readGitCommandFailureStderr(error) ?? readGitCommandFailureText(error) + const diagnostic = text.trim().replace(/^fatal: Path /, 'fatal: path ') + const prefix = `fatal: path '${filePath}' ` + const endings = + oid === undefined + ? [ + 'exists on disk, but not in the index', + 'does not exist (neither on disk nor in the index)' + ] + : [`exists on disk, but not in '${oid}'`, `does not exist in '${oid}'`] + return endings.some( + (ending) => diagnostic === `${prefix}${ending}` || diagnostic === `${prefix}${ending}.` + ) +} diff --git a/src/shared/git-branch-line-total.test.ts b/src/shared/git-branch-line-total.test.ts index 1716da811d6..b6a050e1d4f 100644 --- a/src/shared/git-branch-line-total.test.ts +++ b/src/shared/git-branch-line-total.test.ts @@ -193,6 +193,8 @@ describe('buildGitBranchLineTotalDiffArgs', () => { expect(buildGitBranchLineTotalDiffArgs(MERGE_BASE)).toEqual([ '-c', 'core.quotePath=false', + '-c', + 'diff.autoRefreshIndex=false', 'diff', '-z', '--numstat', diff --git a/src/shared/git-branch-line-total.ts b/src/shared/git-branch-line-total.ts index 2898ad1ef36..9b58af6ca4f 100644 --- a/src/shared/git-branch-line-total.ts +++ b/src/shared/git-branch-line-total.ts @@ -65,7 +65,18 @@ export async function settleGitBranchLineTotalWithinSoftDeadline(input: { * OID parsed as a rev even if a path of the same name exists. */ export function buildGitBranchLineTotalDiffArgs(mergeBase: string): string[] { - return ['-c', 'core.quotePath=false', 'diff', '-z', '--numstat', '-M', mergeBase, '--'] + return [ + '-c', + 'core.quotePath=false', + '-c', + 'diff.autoRefreshIndex=false', + 'diff', + '-z', + '--numstat', + '-M', + mergeBase, + '--' + ] } /** diff --git a/src/shared/git-change-list.test.ts b/src/shared/git-change-list.test.ts new file mode 100644 index 00000000000..cf6fd2ff96a --- /dev/null +++ b/src/shared/git-change-list.test.ts @@ -0,0 +1,109 @@ +import { execFile } from 'node:child_process' +import { mkdtemp, rename, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { promisify } from 'node:util' +import { describe, expect, it } from 'vitest' +import { gitChangeListArgs, parseGitChangeList } from './git-change-list' + +const execFileAsync = promisify(execFile) +const raw = (status: string, ...paths: string[]): string => + `:100644 100644 abc def ${status}\0${paths.join('\0')}\0` + +describe('Git change lists', () => { + it('preserves delimiters, quotes, Unicode and rename markers in paths', () => { + const name = ':tab\tnewline\n"日本語" => file' + const oldPath = 'old\t\nfile' + expect( + parseGitChangeList( + [ + raw('M', name), + raw('R100', oldPath, 'new'), + `2\t1\t${name}\0`, + '0\t0\t\0old\t\nfile\0new\0' + ].join('') + ) + ).toEqual([ + { path: name, status: 'modified', added: 2, removed: 1 }, + { path: 'new', oldPath, status: 'renamed', added: 0, removed: 0 } + ]) + }) + + it('keeps copies, binary changes and type changes', () => { + expect( + parseGitChangeList( + [ + raw('C080', 'source', 'copy'), + raw('M', 'binary'), + raw('T', 'type'), + '1\t0\t\0source\0copy\0', + '-\t-\tbinary\0', + '0\t0\ttype\0' + ].join('') + ) + ).toEqual([ + { path: 'copy', oldPath: 'source', status: 'copied', added: 1, removed: 0 }, + { path: 'binary', status: 'modified', added: undefined, removed: undefined }, + { path: 'type', status: 'modified', added: 0, removed: 0 } + ]) + }) + + it('handles empty output and rejects truncated paths rather than partial changes', () => { + expect(parseGitChangeList('')).toEqual([]) + expect(() => parseGitChangeList(':100644 100644 a b M\0unterminated')).toThrow('Incomplete') + expect(() => parseGitChangeList(raw('R100', 'old'))).toThrow('Incomplete') + expect(() => parseGitChangeList(':invalid\0name\0')).toThrow('Invalid') + }) + + it('reads root, branch and parent comparisons from a real repository', async () => { + const repo = await mkdtemp(join(tmpdir(), 'orca-change-list-')) + const git = async (args: string[]): Promise => { + const { stdout } = await execFileAsync('git', args, { cwd: repo }) + return stdout + } + try { + await git(['init', '-q']) + await git(['config', 'user.email', 'test@example.invalid']) + await git(['config', 'user.name', 'Test']) + await git(['config', 'commit.gpgSign', 'false']) + await writeFile(join(repo, 'source'), 'one\ntwo\n') + await writeFile(join(repo, 'old'), 'unique rename contents\n') + await writeFile(join(repo, 'deleted'), 'delete\n') + await writeFile(join(repo, 'binary'), Buffer.from([0, 1])) + await git(['add', '.']) + await git(['commit', '-qm', 'root']) + const base = (await git(['rev-parse', 'HEAD'])).trim() + expect(parseGitChangeList(await git(gitChangeListArgs(null, base)))).toEqual([ + { path: 'binary', status: 'added', added: undefined, removed: undefined }, + { path: 'deleted', status: 'added', added: 1, removed: 0 }, + { path: 'old', status: 'added', added: 1, removed: 0 }, + { path: 'source', status: 'added', added: 2, removed: 0 } + ]) + const renamed = process.platform === 'win32' ? 'new' : 'new\t\n日本語 => file' + await rename(join(repo, 'old'), join(repo, renamed)) + await rm(join(repo, 'deleted')) + await writeFile(join(repo, 'copy'), 'one\ntwo\n') + await writeFile(join(repo, 'source'), 'one\ntwo\nthree\n') + await writeFile(join(repo, 'binary'), Buffer.from([0, 2])) + await writeFile(join(repo, 'empty'), '') + await git(['add', '.']) + await git(['commit', '-qm', 'changes']) + const head = (await git(['rev-parse', 'HEAD'])).trim() + const entries = parseGitChangeList(await git(gitChangeListArgs(base, head))) + expect(entries).toEqual( + expect.arrayContaining([ + { path: 'binary', status: 'modified', added: undefined, removed: undefined }, + { path: 'copy', oldPath: 'source', status: 'copied', added: 0, removed: 0 }, + { path: 'deleted', status: 'deleted', added: 0, removed: 1 }, + { path: 'empty', status: 'added', added: 0, removed: 0 }, + { path: renamed, oldPath: 'old', status: 'renamed', added: 0, removed: 0 }, + { path: 'source', status: 'modified', added: 1, removed: 0 } + ]) + ) + expect(entries).toHaveLength(6) + expect(parseGitChangeList(await git(gitChangeListArgs(head, head)))).toEqual([]) + } finally { + await rm(repo, { recursive: true, force: true }) + } + }) +}) diff --git a/src/shared/git-change-list.ts b/src/shared/git-change-list.ts new file mode 100644 index 00000000000..72ff5fdbf89 --- /dev/null +++ b/src/shared/git-change-list.ts @@ -0,0 +1,54 @@ +import type { GitBranchChangeEntry } from './git-diff-compare-types' +import type { GitBranchChangeStatus } from './git-status-types' +import { parseNumstat } from './git-uncommitted-line-stats' + +const CHANGE_STATUS: Record = { + A: 'added', + D: 'deleted', + R: 'renamed', + C: 'copied' +} + +export function gitChangeListArgs(fromOid: string | null, toOid: string): string[] { + const format = ['--raw', '--numstat', '-z', '-M', '-C'] + return fromOid + ? ['diff', ...format, fromOid, toOid, '--'] + : ['diff-tree', '--root', '--no-commit-id', '-r', ...format, toOid, '--'] +} + +export function parseGitChangeList(stdout: string): GitBranchChangeEntry[] { + const entries: GitBranchChangeEntry[] = [] + let offset = 0 + function readField(): string { + const end = stdout.indexOf('\0', offset) + if (end === -1) { + throw new Error('Incomplete Git change record') + } + const value = stdout.slice(offset, end) + offset = end + 1 + return value + } + + // Raw records precede numstat records; filenames are separate NUL-delimited fields. + while (stdout[offset] === ':') { + const header = readField() + const match = /^:[0-7]{6} [0-7]{6} [0-9a-f]+ [0-9a-f]+ ([A-Z])\d*$/.exec(header) + if (!match) { + throw new Error('Invalid Git change record') + } + const code = match[1] ?? '' + const firstPath = readField() + const oldPath = code === 'R' || code === 'C' ? firstPath : undefined + const path = oldPath === undefined ? firstPath : readField() + if (!path || oldPath === '') { + throw new Error('Missing Git change path') + } + entries.push({ + path, + status: CHANGE_STATUS[code] ?? 'modified', + ...(oldPath === undefined ? {} : { oldPath }) + }) + } + const statsByPath = parseNumstat(stdout.slice(offset)) + return entries.map((entry) => ({ ...entry, ...statsByPath.get(entry.path) })) +} diff --git a/src/shared/git-command-classification.ts b/src/shared/git-command-classification.ts new file mode 100644 index 00000000000..2a3980fd8b1 --- /dev/null +++ b/src/shared/git-command-classification.ts @@ -0,0 +1,181 @@ +/** + * Decide whether a git invocation is a plain read that can run without a shell. + * + * Why: WSL-routed git otherwise goes through the distro user's interactive login + * shell, purely to inherit their PATH. That shell also runs the distro's rc/motd + * and writes it to the stdout callers parse. Reads need none of it -- the direct + * route supplies PATH and HOME explicitly and starts no shell at all. + * + * Writes and network operations stay on the login shell: they can depend on + * credential helpers, ssh-agent and other environment only the user's profile + * sets up. + */ + +// Subcommands that only ever read. `status` is here for completeness; its +// callers already opted in explicitly. +const ALWAYS_READ_SUBCOMMANDS = new Set([ + 'blame', + 'cat-file', + 'check-ref-format', + 'check-ignore', + 'describe', + 'diff', + 'diff-tree', + 'for-each-ref', + 'log', + 'ls-files', + 'ls-tree', + 'merge-base', + 'name-rev', + 'rev-list', + 'rev-parse', + 'show', + 'show-ref', + 'status', + 'var' +]) + +// Read markers that appear as a flag anywhere after the subcommand. +const READ_FLAG_SUBCOMMANDS: Record> = { + branch: new Set([ + '--list', + '-l', + '--show-current', + '--contains', + '--points-at', + '--all', + '-a', + '--remotes', + '-r' + ]), + config: new Set(['--get', '--get-all', '--get-regexp', '--get-urlmatch', '--list', '-l']) +} + +const BRANCH_MUTATION_FLAGS = new Set([ + '--copy', + '--create-reflog', + '--delete', + '--edit-description', + '--force', + '--move', + '--no-create-reflog', + '--no-track', + '--recurse-submodules', + '--set-upstream-to', + '--track', + '--unset-upstream' +]) +const BRANCH_MUTATION_SHORT_FLAGS = new Set(['c', 'C', 'd', 'D', 'f', 'm', 'M', 't', 'u']) + +function hasBranchMutationFlag(args: readonly string[]): boolean { + return args.some((arg) => { + const flag = arg.split('=')[0] + if (BRANCH_MUTATION_FLAGS.has(flag)) { + return true + } + return ( + /^-[^-]/.test(flag) && + flag + .slice(1) + .split('') + .some((part) => BRANCH_MUTATION_SHORT_FLAGS.has(part)) + ) + }) +} + +// Read markers that must be the *first non-flag* argument, i.e. the action. +// Position matters here: matching them anywhere would read `worktree remove list` +// as a listing, because a worktree may legitimately be named "list". +const READ_ACTION_SUBCOMMANDS: Record> = { + remote: new Set(['get-url']), + submodule: new Set(['status']), + worktree: new Set(['list']) +} + +// Subcommands whose action-less form only lists (`git remote`, `git submodule`). +const BARE_FORM_IS_READ = new Set(['remote', 'submodule']) + +/** Leading `-c key=value` / `--git-dir=...` style options precede the subcommand. */ +export function findGitSubcommandIndex(args: readonly string[]): number { + for (let index = 0; index < args.length; index += 1) { + const arg = args[index] + if (arg === '-c' || arg === '-C') { + index += 1 + continue + } + if (arg.startsWith('-')) { + continue + } + return index + } + return -1 +} + +export function isWslDirectGitReadCommand(args: readonly string[]): boolean { + const subcommandIndex = findGitSubcommandIndex(args) + if (subcommandIndex === -1) { + return false + } + const subcommand = args[subcommandIndex] + if (ALWAYS_READ_SUBCOMMANDS.has(subcommand)) { + return true + } + const rest = args.slice(subcommandIndex + 1) + + if (subcommand === 'symbolic-ref') { + if (rest.some((arg) => arg === '-d' || arg === '--delete' || arg === '-m')) { + return false + } + // Reading takes one ref; a second positional is the value being written. + return rest.filter((arg) => arg !== '--' && !arg.startsWith('-')).length <= 1 + } + + if (subcommand === 'branch' && hasBranchMutationFlag(rest)) { + return false + } + + const readActions = READ_ACTION_SUBCOMMANDS[subcommand] + if (readActions) { + const action = rest.find((arg) => !arg.startsWith('-')) + if (!action) { + return BARE_FORM_IS_READ.has(subcommand) + } + // `remote show` queries the transport unless -n is given, so the queried + // form has to keep the profile's SSH and credential setup. + if (subcommand === 'remote' && action === 'show') { + return rest.includes('-n') + } + return readActions.has(action) + } + + const readFlags = READ_FLAG_SUBCOMMANDS[subcommand] + return Boolean(readFlags && rest.some((arg) => readFlags.has(arg.split('=')[0]))) +} + +export type GitCommandClass = 'network' | 'read' | 'other' + +const NETWORK_SUBCOMMANDS = new Set(['fetch', 'pull', 'push', 'clone', 'ls-remote']) + +function positionalAction(args: readonly string[], subcommandIndex: number): string | undefined { + return args.slice(subcommandIndex + 1).find((arg) => !arg.startsWith('-')) +} + +/** Classify only commands whose dominant phase is remote transfer as network work. */ +export function classifyGitCommand(args: readonly string[]): GitCommandClass { + const subcommandIndex = findGitSubcommandIndex(args) + if (subcommandIndex === -1) { + return 'other' + } + const subcommand = args[subcommandIndex] + if (NETWORK_SUBCOMMANDS.has(subcommand)) { + return 'network' + } + const action = positionalAction(args, subcommandIndex) + if ( + (subcommand === 'submodule' && action === 'update') || + (subcommand === 'remote' && action === 'update') + ) { + return 'network' + } + return isWslDirectGitReadCommand(args) ? 'read' : 'other' +} diff --git a/src/shared/git-command-failure-text.ts b/src/shared/git-command-failure-text.ts index 1ebfd322cfe..92265cab98d 100644 --- a/src/shared/git-command-failure-text.ts +++ b/src/shared/git-command-failure-text.ts @@ -25,3 +25,16 @@ export function readGitCommandFailureText(error: unknown): string { } return parts.join('\n') } + +export function readGitCommandFailureStderr(error: unknown): string | null { + if (typeof error !== 'object' || error === null || !('stderr' in error)) { + return null + } + if (typeof error.stderr === 'string') { + return error.stderr + } + if (typeof Buffer !== 'undefined' && Buffer.isBuffer(error.stderr)) { + return error.stderr.toString('utf8') + } + return null +} diff --git a/src/shared/git-command-timeout.ts b/src/shared/git-command-timeout.ts new file mode 100644 index 00000000000..a889e2ed046 --- /dev/null +++ b/src/shared/git-command-timeout.ts @@ -0,0 +1,23 @@ +import { classifyGitCommand } from './git-command-classification' + +export const GIT_READ_TIMEOUT_MS = 120_000 + +export class GitCommandTimeoutError extends Error { + readonly timeoutMs: number + + constructor(timeoutMs: number) { + super('git timed out.') + this.name = 'GitCommandTimeoutError' + this.timeoutMs = timeoutMs + } +} + +export function gitCommandTimeoutMs( + args: readonly string[], + explicitTimeoutMs: number | undefined, + defaultReadTimeoutMs = GIT_READ_TIMEOUT_MS +): number | undefined { + return ( + explicitTimeoutMs ?? (classifyGitCommand(args) === 'read' ? defaultReadTimeoutMs : undefined) + ) +} diff --git a/src/shared/git-common-directory.ts b/src/shared/git-common-directory.ts new file mode 100644 index 00000000000..c36b7244d3d --- /dev/null +++ b/src/shared/git-common-directory.ts @@ -0,0 +1,64 @@ +import { readFile, stat } from 'node:fs/promises' +import path from 'node:path' +import { waitForPromiseWithSignal } from './abort-signal-reason' +import { resolveGitMetadataPath, type GitMetadataPathOptions } from './git-metadata-path' +import { parseGitdirMarkerPayload } from './gitdir-marker-payload' + +export type GitAdminReadOptions = GitMetadataPathOptions & { signal?: AbortSignal } + +export function isMissingGitAdminEntry(error: unknown): boolean { + return ( + error instanceof Error && + 'code' in error && + (error.code === 'ENOENT' || error.code === 'ENOTDIR') + ) +} + +export async function readGitAdminFile( + filePath: string, + signal?: AbortSignal +): Promise { + signal?.throwIfAborted() + try { + return await readFile(filePath, { encoding: 'utf8', signal }) + } catch (error) { + if (isMissingGitAdminEntry(error)) { + return null + } + throw error + } +} + +/** Read the owning host's Git layout without starting a subprocess. */ +export async function resolveGitCommonDirectory( + repoPath: string, + options: GitAdminReadOptions = {} +): Promise { + const dotGit = path.join(repoPath, '.git') + let gitDir: string | null = null + try { + const metadata = await waitForPromiseWithSignal(stat(dotGit), options.signal) + if (metadata.isDirectory()) { + gitDir = dotGit + } else if (metadata.isFile()) { + const marker = parseGitdirMarkerPayload( + (await readGitAdminFile(dotGit, options.signal)) ?? '' + ) + if (marker) { + gitDir = resolveGitMetadataPath(repoPath, marker, options) + } + } + } catch (error) { + if (!isMissingGitAdminEntry(error)) { + throw error + } + if ((await readGitAdminFile(path.join(repoPath, 'HEAD'), options.signal)) !== null) { + gitDir = repoPath + } + } + if (!gitDir) { + return null + } + const commonDir = (await readGitAdminFile(path.join(gitDir, 'commondir'), options.signal))?.trim() + return commonDir ? resolveGitMetadataPath(gitDir, commonDir, options) : gitDir +} diff --git a/src/shared/git-default-base-ref.ts b/src/shared/git-default-base-ref.ts new file mode 100644 index 00000000000..539b0f57300 --- /dev/null +++ b/src/shared/git-default-base-ref.ts @@ -0,0 +1,37 @@ +import { iterateProcessOutputLines } from './process-output-field-scanner' + +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' } +] + +export type GitExec = (argv: string[]) => Promise<{ stdout: string }> + +/** Resolve the same default-base ordering through a host-owned Git executor. */ +export async function resolveDefaultBaseRefViaExec(exec: GitExec): Promise { + const originHeadRef = 'refs/remotes/origin/HEAD' + const refs = [originHeadRef, ...DEFAULT_BASE_REF_PROBES.map(({ ref }) => ref)] + // A character class forces exact matching instead of including descendants such as main/topic. + const patterns = refs.map((ref) => `${ref.slice(0, -1)}[${ref.slice(-1)}]`) + try { + const { stdout } = await exec(['for-each-ref', '--format=%(refname)%00%(symref)', ...patterns]) + const presentRefs = new Set() + for (const line of iterateProcessOutputLines(stdout)) { + const separator = line.indexOf('\0') + if (separator === -1) { + continue + } + const ref = line.slice(0, separator) + const target = line.slice(separator + 1) + if (ref === originHeadRef && target) { + return target.replace(/^refs\/remotes\//, '') + } + presentRefs.add(ref) + } + return DEFAULT_BASE_REF_PROBES.find(({ ref }) => presentRefs.has(ref))?.returnAs ?? null + } catch { + return null + } +} diff --git a/src/shared/git-effective-upstream.ts b/src/shared/git-effective-upstream.ts index db364c53b20..4fd7de3e62d 100644 --- a/src/shared/git-effective-upstream.ts +++ b/src/shared/git-effective-upstream.ts @@ -7,6 +7,7 @@ import { import { splitRemoteBranchName } from './git-remote-branch-name' import { parseGitRevListAheadBehindCounts } from './git-rev-list-output' import { iterateProcessOutputLines } from './process-output-field-scanner' +import { createGitConfigSnapshotRunner } from './git-config-snapshot-runner' export { gitRefTargetsBranchName, splitRemoteBranchName } from './git-remote-branch-name' @@ -183,15 +184,17 @@ async function resolveEffectiveGitUpstreamForBranch( } export async function resolveEffectiveGitUpstream( - runGit: GitCommandRunner + execGit: GitCommandRunner ): Promise { + const runGit = createGitConfigSnapshotRunner(execGit) return resolveEffectiveGitUpstreamForBranch(runGit, await getCurrentBranchName(runGit)) } export async function getEffectiveGitUpstreamStatus( - runGit: GitCommandRunner, + execGit: GitCommandRunner, getBehindCommitsArePatchEquivalent?: (upstreamName: string) => Promise ): Promise { + const runGit = createGitConfigSnapshotRunner(execGit) const currentBranchName = await getCurrentBranchName(runGit) const upstream = await resolveEffectiveGitUpstreamForBranch(runGit, currentBranchName) if (!upstream) { diff --git a/src/shared/git-fork-sync.test.ts b/src/shared/git-fork-sync.test.ts index 32462ebe975..5fb6abd81ed 100644 --- a/src/shared/git-fork-sync.test.ts +++ b/src/shared/git-fork-sync.test.ts @@ -66,6 +66,7 @@ describe('syncForkDefaultBranch', () => { const result = await syncForkDefaultBranch(runGit) expect(result).toMatchObject({ status: 'synced', branchName: 'main', ahead: 0, behind: 3 }) + expect(calls.filter((args) => args[0] === 'remote' && args.length === 1)).toEqual([['remote']]) expect(calls).toContainEqual([ 'push', 'origin', @@ -148,12 +149,23 @@ describe('syncForkDefaultBranch', () => { }) it('blocks when the upstream remote is missing', async () => { - const { runGit } = createRunner({ remotes: 'origin\n' }) + const { runGit, calls } = createRunner({ remotes: 'origin\n' }) await expect(syncForkDefaultBranch(runGit)).resolves.toMatchObject({ status: 'blocked', reason: 'missing-upstream' }) + expect(calls).toEqual([['remote']]) + }) + + it('reports missing origin first when both remotes are missing', async () => { + const { runGit, calls } = createRunner({ remotes: '' }) + + await expect(syncForkDefaultBranch(runGit)).resolves.toMatchObject({ + status: 'blocked', + reason: 'missing-origin' + }) + expect(calls).toEqual([['remote']]) }) it('blocks when the upstream remote no longer matches the expected fork metadata', async () => { diff --git a/src/shared/git-fork-sync.ts b/src/shared/git-fork-sync.ts index 656dd29267c..4dc0e688860 100644 --- a/src/shared/git-fork-sync.ts +++ b/src/shared/git-fork-sync.ts @@ -50,16 +50,6 @@ function parseAheadBehind(stdout: string): { ahead: number; behind: number } { } } -async function remoteExists(runGit: GitForkSyncRunner, remote: string): Promise { - const { stdout } = await runGit(['remote']) - for (const rawLine of iterateGitOutputLines(stdout)) { - if (rawLine.trim() === remote) { - return true - } - } - return false -} - function* iterateGitOutputLines(output: string): Generator { let lineStart = 0 @@ -247,11 +237,12 @@ export async function syncForkDefaultBranch( const upstreamRemote = options.upstreamRemote ?? DEFAULT_UPSTREAM_REMOTE const expectedUpstream = validateGitForkSyncExpectedUpstream(options.expectedUpstream) const baseResult = { originRemote, upstreamRemote, ahead: 0, behind: 0 } - - if (!(await remoteExists(runGit, originRemote))) { + const { stdout: remoteStdout } = await runGit(['remote']) + const remotes = new Set(Array.from(iterateGitOutputLines(remoteStdout), (line) => line.trim())) + if (!remotes.has(originRemote)) { return { ...baseResult, status: 'blocked', reason: 'missing-origin' } } - if (!(await remoteExists(runGit, upstreamRemote))) { + if (!remotes.has(upstreamRemote)) { return { ...baseResult, status: 'blocked', reason: 'missing-upstream' } } if ( diff --git a/src/shared/git-history.ts b/src/shared/git-history.ts index e45142d4cb1..883c333756b 100644 --- a/src/shared/git-history.ts +++ b/src/shared/git-history.ts @@ -209,6 +209,8 @@ export async function loadGitHistoryFromExecutor( const { stdout } = await git( [ 'log', + '--no-show-signature', + '--no-color', `--format=${GIT_HISTORY_COMMIT_FORMAT}`, '-z', '--topo-order', diff --git a/src/shared/git-pathspec-stdin.ts b/src/shared/git-pathspec-stdin.ts new file mode 100644 index 00000000000..1e67bb54e60 --- /dev/null +++ b/src/shared/git-pathspec-stdin.ts @@ -0,0 +1,6 @@ +export function encodeGitPathspecs(pathspecs: readonly string[]): string { + if (pathspecs.some((pathspec) => pathspec.includes('\0'))) { + throw new Error('Git pathspecs cannot contain NUL bytes') + } + return pathspecs.length > 0 ? `${pathspecs.join('\0')}\0` : '' +} diff --git a/src/shared/git-push-target-resolution.ts b/src/shared/git-push-target-resolution.ts index 63fe7828d9e..78f58af9e8d 100644 --- a/src/shared/git-push-target-resolution.ts +++ b/src/shared/git-push-target-resolution.ts @@ -1,6 +1,7 @@ import type { GitCommandRunner } from './git-effective-upstream' import { gitRefTargetsBranchOnRemote } from './git-remote-branch-name' import { findGitRemoteNameByFetchUrl } from './git-remote-url-index' +import { createGitConfigSnapshotRunner } from './git-config-snapshot-runner' export type ResolvedGitPushTarget = { remote: string @@ -109,22 +110,28 @@ function canPushConfiguredMergeBranch( * for the same repository — they differ only in how `runGit` reaches the Git binary. */ export async function resolveConfiguredGitPushTarget( - runGit: GitCommandRunner + execGit: GitCommandRunner ): Promise { + const runGit = createGitConfigSnapshotRunner(execGit) try { const { stdout: branchStdout } = await runGit(['symbolic-ref', '--quiet', '--short', 'HEAD']) const branch = branchStdout.trim() if (!branch) { return null } - const [pushRemote, { stdout: mergeStdout }] = await Promise.all([ + const [pushRemote, mergeRef] = await Promise.all([ getConfiguredPushRemote(runGit, branch), - runGit(['config', '--get', `branch.${branch}.merge`]) + getConfigValue(runGit, `branch.${branch}.merge`) ]) const remote = pushRemote?.remote - const mergeRef = mergeStdout.trim() + if (!remote || remote === '.') { + return null + } + if (!mergeRef) { + return { remote, refspec: 'HEAD' } + } const branchRef = mergeRef.replace(/^refs\/heads\//, '') - if (!remote || !branchRef || remote === '.' || branchRef === mergeRef) { + if (!branchRef || branchRef === mergeRef) { return null } if (await branchMergeTargetsConfiguredBase(runGit, branch, remote, branchRef)) { diff --git a/src/shared/git-resolution-binary-compatibility.test-cases.ts b/src/shared/git-resolution-binary-compatibility.test-cases.ts new file mode 100644 index 00000000000..807c66984d9 --- /dev/null +++ b/src/shared/git-resolution-binary-compatibility.test-cases.ts @@ -0,0 +1,89 @@ +import { expect, it } from 'vitest' +import { resolveConfiguredGitPushTarget } from './git-push-target-resolution' +import { resolveDefaultBaseRefViaExec } from './git-default-base-ref' +import { buildGitSshPolicyEnv, GIT_SSH_CONFIG_ARGS, parseGitSshConfig } from './git-ssh-policy-env' + +export function registerGitResolutionBinaryCompatibilityCases( + runGit: (args: string[]) => Promise<{ stdout: string; stderr: string }>, + resolveRemotePath: (name: string) => string +): void { + it('resolves push config from one snapshot and refreshes after a config write', async () => { + const branch = (await runGit(['symbolic-ref', '--quiet', '--short', 'HEAD'])).stdout.trim() + await runGit(['config', `branch.${branch}.remote`, 'fork']) + await runGit(['config', `branch.${branch}.merge`, `refs/heads/${branch}`]) + const calls: string[][] = [] + const exec = async (args: string[]) => { + calls.push(args) + return runGit(args) + } + + await expect(resolveConfiguredGitPushTarget(exec)).resolves.toEqual({ + remote: 'fork', + refspec: `HEAD:${branch}` + }) + await runGit(['config', `branch.${branch}.pushRemote`, 'other-fork']) + await expect(resolveConfiguredGitPushTarget(exec)).resolves.toEqual({ + remote: 'other-fork', + refspec: `HEAD:${branch}` + }) + expect(calls.filter((args) => args[0] === 'config')).toEqual([ + ['config', '--list', '-z'], + ['config', '--list', '-z'] + ]) + for (const name of ['fork', 'other-fork']) { + const remotePath = resolveRemotePath(name) + await runGit(['init', '--bare', '-q', remotePath]) + await runGit(['remote', 'add', name, remotePath]) + } + await runGit(['config', '--unset', `branch.${branch}.merge`]) + await runGit(['config', 'remote.pushDefault', 'fork']) + const firstPush = await resolveConfiguredGitPushTarget(exec) + expect(firstPush).toEqual({ remote: 'other-fork', refspec: 'HEAD' }) + if (!firstPush) { + throw new Error('missing first-publish target') + } + await runGit(['push', '--set-upstream', firstPush.remote, firstPush.refspec]) + const head = (await runGit(['rev-parse', 'HEAD'])).stdout + expect( + (await runGit(['--git-dir=other-fork.git', 'rev-parse', `refs/heads/${branch}`])).stdout + ).toBe(head) + await expect( + runGit(['--git-dir=fork.git', 'show-ref', '--verify', `refs/heads/${branch}`]) + ).rejects.toThrow() + }) + + it('reads SSH command and variant together with baseline-compatible NUL output', async () => { + await runGit(['config', 'core.sshCommand', 'ssh -i "key with spaces"']) + await runGit(['config', 'ssh.variant', 'simple']) + const config = parseGitSshConfig((await runGit(GIT_SSH_CONFIG_ARGS)).stdout) + expect(config).toEqual({ command: 'ssh -i "key with spaces"', variant: 'simple' }) + expect( + buildGitSshPolicyEnv({}, config.command, config.variant).env.GIT_SSH_COMMAND + ).toBeUndefined() + const overridden = parseGitSshConfig( + (await runGit(['-c', 'ssh.variant=ssh', ...GIT_SSH_CONFIG_ARGS])).stdout + ) + expect( + buildGitSshPolicyEnv({}, overridden.command, overridden.variant).env.GIT_SSH_COMMAND + ).toBe("ssh -i 'key with spaces' -o BatchMode=yes") + }) + + it('resolves default bases from exact refs with symbolic chains and dangling targets', async () => { + const head = (await runGit(['rev-parse', 'HEAD'])).stdout.trim() + await runGit(['update-ref', '-d', 'refs/remotes/origin/main']) + await runGit(['update-ref', 'refs/remotes/origin/main/topic', head]) + await runGit(['update-ref', 'refs/remotes/origin/master', head]) + await runGit(['update-ref', 'refs/remotes/origin/release/stable', head]) + await runGit([ + 'symbolic-ref', + 'refs/remotes/origin/alias', + 'refs/remotes/origin/release/stable' + ]) + await runGit(['symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/alias']) + await expect(resolveDefaultBaseRefViaExec(runGit)).resolves.toBe('origin/release/stable') + await runGit(['update-ref', '-d', 'refs/remotes/origin/release/stable']) + await expect(resolveDefaultBaseRefViaExec(runGit)).resolves.toBe('origin/master') + await runGit(['update-ref', '--no-deref', 'refs/remotes/origin/HEAD', head]) + await expect(resolveDefaultBaseRefViaExec(runGit)).resolves.toBe('origin/master') + }) +} diff --git a/src/shared/git-resolution-config-snapshot.test.ts b/src/shared/git-resolution-config-snapshot.test.ts new file mode 100644 index 00000000000..c776050b3d5 --- /dev/null +++ b/src/shared/git-resolution-config-snapshot.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it, vi } from 'vitest' +import { + getEffectiveGitUpstreamStatus, + resolveEffectiveGitUpstream +} from './git-effective-upstream' +import { resolveConfiguredGitPushTarget } from './git-push-target-resolution' + +function createRunner(config = new Map(), snapshotFails = false) { + return vi.fn(async (args: string[]) => { + if (args[0] === 'symbolic-ref') { + return { stdout: 'feature\n' } + } + if (args[0] === 'config' && args[1] === '--list') { + if (snapshotFails) { + throw new Error('snapshot unavailable') + } + return { stdout: Array.from(config, ([key, value]) => `${key}\n${value}\0`).join('') } + } + if (args[0] === 'config' && args[1] === '--get') { + const value = config.get((args[2] ?? '').toLowerCase()) + if (value === undefined) { + throw new Error('missing config key') + } + return { stdout: `${value}\n` } + } + if (args[0] === 'rev-parse') { + throw new Error(args.includes('HEAD@{u}') ? 'fatal: no upstream configured' : 'missing ref') + } + throw new Error(`unexpected Git command: ${args.join(' ')}`) + }) +} + +describe('resolution config snapshots', () => { + it('shares one snapshot across upstream fallback and push-target status checks', async () => { + const runGit = createRunner() + + await expect(getEffectiveGitUpstreamStatus(runGit)).resolves.toEqual({ + hasUpstream: false, + ahead: 0, + behind: 0 + }) + + expect(runGit.mock.calls.filter(([args]) => args[0] === 'config')).toEqual([ + [['config', '--list', '-z']] + ]) + expect(runGit).toHaveBeenCalledTimes(4) + }) + + it('shares one snapshot across pull upstream config lookups', async () => { + const runGit = createRunner() + + await expect(resolveEffectiveGitUpstream(runGit)).resolves.toBeNull() + + expect(runGit.mock.calls.filter(([args]) => args[0] === 'config')).toEqual([ + [['config', '--list', '-z']] + ]) + }) + + it('reads a fresh push snapshot after branch config changes', async () => { + const config = new Map([ + ['branch.feature.remote', 'fork'], + ['branch.feature.merge', 'refs/heads/feature'] + ]) + const runGit = createRunner(config) + + await expect(resolveConfiguredGitPushTarget(runGit)).resolves.toEqual({ + remote: 'fork', + refspec: 'HEAD:feature' + }) + config.set('remote.pushdefault', 'other-fork') + await expect(resolveConfiguredGitPushTarget(runGit)).resolves.toEqual({ + remote: 'other-fork', + refspec: 'HEAD:feature' + }) + + expect(runGit.mock.calls.filter(([args]) => args[0] === 'config')).toEqual([ + [['config', '--list', '-z']], + [['config', '--list', '-z']] + ]) + }) + + it('falls back to individual config reads once when the snapshot fails', async () => { + const runGit = createRunner( + new Map([ + ['branch.feature.remote', 'fork'], + ['branch.feature.merge', 'refs/heads/feature'] + ]), + true + ) + + await expect(resolveConfiguredGitPushTarget(runGit)).resolves.toEqual({ + remote: 'fork', + refspec: 'HEAD:feature' + }) + + expect(runGit.mock.calls.filter(([args]) => args[1] === '--list')).toHaveLength(1) + expect(runGit.mock.calls.filter(([args]) => args[1] === '--get')).toHaveLength(5) + }) + + it('skips config reads when the configured upstream resolves directly', async () => { + const runGit = vi.fn(async (args: string[]) => { + if (args[0] === 'symbolic-ref') { + return { stdout: 'feature\n' } + } + if (args[0] === 'rev-parse') { + return { stdout: 'origin/feature\n' } + } + return { stdout: '1\t0\n' } + }) + + await expect(getEffectiveGitUpstreamStatus(runGit)).resolves.toEqual({ + hasUpstream: true, + upstreamName: 'origin/feature', + ahead: 1, + behind: 0 + }) + + expect(runGit.mock.calls.filter(([args]) => args[0] === 'config')).toHaveLength(0) + }) +}) diff --git a/src/shared/git-rev-list-output.test.ts b/src/shared/git-rev-list-output.test.ts index d71f787a9ed..d12f72e7b15 100644 --- a/src/shared/git-rev-list-output.test.ts +++ b/src/shared/git-rev-list-output.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from 'vitest' import { parseGitRevListAheadBehindCounts, - parseGitRevListFirstParentOid + parseGitRevListFirstParentOid, + parseGitRevListCommitAndFirstParentOid } from './git-rev-list-output' describe('parseGitRevListAheadBehindCounts', () => { @@ -43,3 +44,25 @@ describe('parseGitRevListFirstParentOid', () => { expect(parseGitRevListFirstParentOid('commit-oid\n')).toBeNull() }) }) + +describe('parseGitRevListCommitAndFirstParentOid', () => { + it.each([40, 64])('reads SHA-%i commit metadata without retaining later parents', (length) => { + expect( + parseGitRevListCommitAndFirstParentOid( + `${'a'.repeat(length)} ${'b'.repeat(length)} ${'c'.repeat(length)}\n` + ) + ).toEqual({ + commitOid: 'a'.repeat(length), + parentOid: 'b'.repeat(length) + }) + }) + + it('preserves a root commit and rejects empty or malformed answers', () => { + expect(parseGitRevListCommitAndFirstParentOid(`${'a'.repeat(40)}\n`)).toEqual({ + commitOid: 'a'.repeat(40), + parentOid: null + }) + expect(() => parseGitRevListCommitAndFirstParentOid('')).toThrow('Unexpected') + expect(() => parseGitRevListCommitAndFirstParentOid('HEAD\n')).toThrow('Unexpected') + }) +}) diff --git a/src/shared/git-rev-list-output.ts b/src/shared/git-rev-list-output.ts index d64685ba62e..2a745c86b83 100644 --- a/src/shared/git-rev-list-output.ts +++ b/src/shared/git-rev-list-output.ts @@ -30,6 +30,17 @@ export function parseGitRevListFirstParentOid(output: string): string | null { return getProcessOutputFields(output, 2)[1] ?? null } +export function parseGitRevListCommitAndFirstParentOid(output: string): { + commitOid: string + parentOid: string | null +} { + const [commitOid, parentOid] = getProcessOutputFields(output, 2) + if (!commitOid || !/^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/.test(commitOid)) { + throw new Error('Unexpected git rev-list commit output') + } + return { commitOid, parentOid: parentOid ?? null } +} + function parseGitRevListNonNegativeCount(value: string | undefined): number | null { if (!value || !/^\d+$/.test(value)) { return null diff --git a/src/shared/git-ssh-policy-env.test.ts b/src/shared/git-ssh-policy-env.test.ts new file mode 100644 index 00000000000..41cdace5f05 --- /dev/null +++ b/src/shared/git-ssh-policy-env.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import { buildGitSshPolicyEnv, parseGitSshConfig } from './git-ssh-policy-env' + +describe('Git network SSH policy', () => { + it('parses the last matching value without dropping embedded newlines', () => { + expect( + parseGitSshConfig( + 'core.sshcommand\nssh -i first\0ssh.variant\nsimple\0core.sshcommand\nwrapper\nnext\0' + ) + ).toEqual({ command: 'wrapper\nnext', variant: 'simple' }) + expect(parseGitSshConfig('')).toEqual({ command: '', variant: undefined }) + }) + + it.each([ + { GIT_SSH_COMMAND: 'custom-command -i key' }, + { GIT_SSH: 'custom-wrapper' }, + { GIT_SSH: 'custom-wrapper', GIT_SSH_COMMAND: 'explicit-command' } + ])('preserves explicit SSH environment (%j)', (env) => { + expect(buildGitSshPolicyEnv(env, 'ssh -i configured')).toEqual({ env, mode: 'explicit-env' }) + }) + + it.each(['simple', 'plink', 'putty', 'tortoiseplink'])( + 'leaves configured %s variants to Git', + (variant) => { + expect(buildGitSshPolicyEnv({}, 'ssh -i identity', variant)).toEqual({ + env: {}, + mode: 'configured-wrapper-passthrough' + }) + } + ) + + it('gives explicit variant environment precedence over config', () => { + expect( + buildGitSshPolicyEnv({ GIT_SSH_VARIANT: 'simple' }, 'ssh', 'ssh').env.GIT_SSH_COMMAND + ).toBeUndefined() + expect( + buildGitSshPolicyEnv({ GIT_SSH_VARIANT: 'ssh' }, 'ssh', 'simple').env.GIT_SSH_COMMAND + ).toBe('ssh -o BatchMode=yes') + }) + + it.each([ + 'ssh -i "$HOME/key"', + 'ssh -i ~/identity*', + "ssh -i '~/identity'", + 'ssh -i key # comment', + 'ssh -i key\nrecord-access', + 'ssh -i key && record-access', + 'wrapper --account work', + 'plink.exe -i key' + ])('preserves shell and wrapper semantics (%s)', (command) => { + expect(buildGitSshPolicyEnv({}, command).env.GIT_SSH_COMMAND).toBeUndefined() + }) + + it('preserves quoted identity arguments while enforcing OpenSSH batch mode', () => { + expect( + buildGitSshPolicyEnv( + {}, + '"C:/Program Files/Git/usr/bin/ssh.exe" -i "C:/Users/test/key file" -oBatchMode=no' + ).env.GIT_SSH_COMMAND + ).toBe("'C:/Program Files/Git/usr/bin/ssh.exe' -i 'C:/Users/test/key file' -o BatchMode=yes") + }) +}) diff --git a/src/shared/git-ssh-policy-env.ts b/src/shared/git-ssh-policy-env.ts new file mode 100644 index 00000000000..d4a77f5c8dc --- /dev/null +++ b/src/shared/git-ssh-policy-env.ts @@ -0,0 +1,162 @@ +import { quotePosixShell } from './wsl-login-shell-command' + +export type GitSshPolicyMode = + | 'default' + | 'explicit-env' + | 'fallback' + | 'configured-openssh' + | 'configured-wrapper-passthrough' + +export const GIT_SSH_CONFIG_ARGS = [ + 'config', + '--null', + '--get-regexp', + '^(core\\.sshcommand|ssh\\.variant)$' +] + +export function parseGitSshConfig(stdout: string): { command: string; variant?: string } { + let command = '' + let variant: string | undefined + for (const entry of stdout.split('\0')) { + const separator = entry.indexOf('\n') + const key = entry.slice(0, separator) + const value = entry.slice(separator + 1) + if (key === 'core.sshcommand') { + command = value + } + if (key === 'ssh.variant') { + variant = value + } + } + return { command, variant } +} + +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 /[$#*?[\]{}\r\n]/.test(command) || /(?:^|\s)['"]~/.test(command) || 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 function buildGitSshPolicyEnv( + env: NodeJS.ProcessEnv, + configuredCommand: string, + configuredVariant?: string +): { env: NodeJS.ProcessEnv; mode: GitSshPolicyMode } { + if (env.GIT_SSH_COMMAND || env.GIT_SSH) { + return { env, mode: 'explicit-env' } + } + if (!configuredCommand) { + return { env: { ...env, GIT_SSH_COMMAND: 'ssh -o BatchMode=yes' }, mode: 'fallback' } + } + const variant = (env.GIT_SSH_VARIANT ?? configuredVariant)?.toLowerCase() + const batchModeCommand = + !variant || variant === 'ssh' || variant === 'auto' + ? buildOpenSshBatchModeCommand(configuredCommand) + : null + if (!batchModeCommand) { + return { env, mode: 'configured-wrapper-passthrough' } + } + return { + env: { ...env, GIT_SSH_COMMAND: batchModeCommand }, + mode: 'configured-openssh' + } +} diff --git a/src/shared/git-status-read-lease-expiry.test.ts b/src/shared/git-status-read-lease-expiry.test.ts new file mode 100644 index 00000000000..4c5d2c5f20c --- /dev/null +++ b/src/shared/git-status-read-lease-expiry.test.ts @@ -0,0 +1,186 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { GitStatusReadLeaseOwner } from './git-status-read-lease-owner' + +function deferred() { + let resolve: (value: T) => void = () => { + throw new Error('Deferred promise is not initialized') + } + let reject: (error: unknown) => void = () => { + throw new Error('Deferred promise is not initialized') + } + const promise = new Promise((nextResolve, nextReject) => { + resolve = nextResolve + reject = nextReject + }) + return { promise, resolve, reject } +} + +beforeEach(() => vi.useFakeTimers()) +afterEach(() => vi.useRealTimers()) + +describe('Git read lease expiry', () => { + it('joins before expiry, then retries without aborting existing callers', async () => { + const owner = new GitStatusReadLeaseOwner(128, 30_000) + const oldRead = deferred() + const freshRead = deferred() + const signals: AbortSignal[] = [] + const load = vi.fn((signal: AbortSignal) => { + signals.push(signal) + return signals.length === 1 ? oldRead.promise : freshRead.promise + }) + const first = owner.lease('diff', undefined, load) + await vi.advanceTimersByTimeAsync(29_999) + const joined = owner.lease('diff', undefined, load) + expect(load).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(1) + + await vi.advanceTimersByTimeAsync(1) + expect(vi.getTimerCount()).toBe(0) + const fresh = owner.lease('diff', undefined, load) + expect(load).toHaveBeenCalledTimes(2) + expect(signals.every((signal) => !signal.aborted)).toBe(true) + + oldRead.resolve('old') + await expect(Promise.all([first, joined])).resolves.toEqual(['old', 'old']) + const freshJoin = owner.lease('diff', undefined, load) + expect(load).toHaveBeenCalledTimes(2) + expect(vi.getTimerCount()).toBe(1) + freshRead.resolve('fresh') + await expect(Promise.all([fresh, freshJoin])).resolves.toEqual(['fresh', 'fresh']) + expect(vi.getTimerCount()).toBe(0) + }) + + it('keeps cancellation ownership separate after an expired read is replaced', async () => { + const owner = new GitStatusReadLeaseOwner(128, 30_000) + const oldRead = deferred() + const freshRead = deferred() + const signals: AbortSignal[] = [] + const load = vi.fn((signal: AbortSignal) => { + signals.push(signal) + return signals.length === 1 ? oldRead.promise : freshRead.promise + }) + const controller = new AbortController() + const old = owner.lease('diff', controller.signal, load) + await vi.advanceTimersByTimeAsync(30_000) + const fresh = owner.lease('diff', undefined, load) + const rejected = expect(old).rejects.toMatchObject({ name: 'AbortError' }) + controller.abort() + await rejected + expect(signals[0]?.aborted).toBe(true) + expect(signals[1]?.aborted).toBe(false) + expect(vi.getTimerCount()).toBe(1) + + oldRead.reject(new Error('old read aborted')) + await Promise.resolve() + const joined = owner.lease('diff', undefined, load) + expect(load).toHaveBeenCalledTimes(2) + freshRead.resolve('fresh') + await expect(Promise.all([fresh, joined])).resolves.toEqual(['fresh', 'fresh']) + expect(vi.getTimerCount()).toBe(0) + }) + + it.each(['success', 'failure'] as const)('clears the expiry timer after %s', async (outcome) => { + const owner = new GitStatusReadLeaseOwner(128, 30_000) + const pending = deferred() + const read = owner.lease('diff', undefined, () => pending.promise) + expect(vi.getTimerCount()).toBe(1) + if (outcome === 'success') { + pending.resolve('result') + await expect(read).resolves.toBe('result') + } else { + const error = new Error('read failed') + const rejected = expect(read).rejects.toBe(error) + pending.reject(error) + await rejected + } + expect(vi.getTimerCount()).toBe(0) + await expect(owner.lease('diff', undefined, async () => 'retry')).resolves.toBe('retry') + expect(vi.getTimerCount()).toBe(0) + }) + + it('invalidates the old timer without aborting its leases or expiring the replacement', async () => { + const owner = new GitStatusReadLeaseOwner(128, 30_000) + const oldRead = deferred() + const freshRead = deferred() + const signals: AbortSignal[] = [] + const load = vi.fn((signal: AbortSignal) => { + signals.push(signal) + return signals.length === 1 ? oldRead.promise : freshRead.promise + }) + const old = owner.lease('diff', undefined, load) + await vi.advanceTimersByTimeAsync(100) + owner.invalidate() + expect(vi.getTimerCount()).toBe(0) + expect(signals[0]?.aborted).toBe(false) + const fresh = owner.lease('diff', undefined, load) + await vi.advanceTimersByTimeAsync(29_900) + oldRead.resolve('old') + await expect(old).resolves.toBe('old') + const joined = owner.lease('diff', undefined, load) + expect(load).toHaveBeenCalledTimes(2) + expect(vi.getTimerCount()).toBe(1) + freshRead.resolve('fresh') + await expect(Promise.all([fresh, joined])).resolves.toEqual(['fresh', 'fresh']) + expect(vi.getTimerCount()).toBe(0) + }) + + it('clears the timer only when the last pending caller cancels', async () => { + const owner = new GitStatusReadLeaseOwner(128, 30_000) + const pending = deferred() + const firstController = new AbortController() + const secondController = new AbortController() + const signals: AbortSignal[] = [] + const load = vi.fn((signal: AbortSignal) => { + signals.push(signal) + return pending.promise + }) + const first = owner.lease('diff', firstController.signal, load) + const second = owner.lease('diff', secondController.signal, load) + const firstRejected = expect(first).rejects.toMatchObject({ name: 'AbortError' }) + const secondRejected = expect(second).rejects.toMatchObject({ name: 'AbortError' }) + firstController.abort() + await firstRejected + expect(vi.getTimerCount()).toBe(1) + expect(signals[0]?.aborted).toBe(false) + secondController.abort() + await secondRejected + expect(vi.getTimerCount()).toBe(0) + expect(signals[0]?.aborted).toBe(true) + pending.reject(new Error('all callers cancelled')) + await Promise.resolve() + await expect(owner.lease('diff', undefined, async () => 'retry')).resolves.toBe('retry') + expect(vi.getTimerCount()).toBe(0) + }) + + it('does not retain overflow reads or allocate expiry timers for them', async () => { + const owner = new GitStatusReadLeaseOwner(1, 30_000) + const retainedRead = deferred() + const overflowRead = deferred() + const retained = owner.lease('retained', undefined, () => retainedRead.promise) + const overflowLoad = vi.fn(() => overflowRead.promise) + const first = owner.lease('overflow', undefined, overflowLoad) + const second = owner.lease('overflow', undefined, overflowLoad) + expect(overflowLoad).toHaveBeenCalledTimes(2) + expect(vi.getTimerCount()).toBe(1) + overflowRead.resolve('overflow') + await expect(Promise.all([first, second])).resolves.toEqual(['overflow', 'overflow']) + expect(vi.getTimerCount()).toBe(1) + await vi.advanceTimersByTimeAsync(30_000) + expect(vi.getTimerCount()).toBe(0) + retainedRead.resolve('retained') + await expect(retained).resolves.toBe('retained') + }) + + it('leaves expiry disabled for native owners that use the default constructor', async () => { + const owner = new GitStatusReadLeaseOwner() + const pending = deferred() + const load = vi.fn(() => pending.promise) + const first = owner.lease('diff', undefined, load) + await vi.advanceTimersByTimeAsync(120_000) + const second = owner.lease('diff', undefined, load) + expect(load).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + pending.resolve('result') + await expect(Promise.all([first, second])).resolves.toEqual(['result', 'result']) + }) +}) diff --git a/src/shared/git-status-read-lease-owner.ts b/src/shared/git-status-read-lease-owner.ts index a0012da7d3a..ec390891bcd 100644 --- a/src/shared/git-status-read-lease-owner.ts +++ b/src/shared/git-status-read-lease-owner.ts @@ -3,6 +3,7 @@ type StatusReadEntry = { promise: Promise liveLeases: number settled: boolean + timeout?: ReturnType } function getAbortReason(signal: AbortSignal): unknown { @@ -17,6 +18,11 @@ function getAbortReason(signal: AbortSignal): unknown { export class GitStatusReadLeaseOwner { private readonly entries = new Map>() + constructor( + private readonly maxEntries = Infinity, + private readonly maxInFlightMs = 0 + ) {} + lease( key: string, signal: AbortSignal | undefined, @@ -30,9 +36,23 @@ export class GitStatusReadLeaseOwner { if (!entry) { const controller = new AbortController() const promise = load(controller.signal) - const createdEntry = { controller, promise, liveLeases: 0, settled: false } + const createdEntry: StatusReadEntry = { + controller, + promise, + liveLeases: 0, + settled: false + } entry = createdEntry - this.entries.set(key, createdEntry) + if (this.entries.size < this.maxEntries) { + this.entries.set(key, createdEntry) + if (this.maxInFlightMs > 0) { + // Expiry detaches retries; existing callers retain their read and cancellation ownership. + createdEntry.timeout = setTimeout( + () => this.detach(key, createdEntry), + this.maxInFlightMs + ) + } + } void promise.then( () => this.settle(key, createdEntry), () => this.settle(key, createdEntry) @@ -44,6 +64,9 @@ export class GitStatusReadLeaseOwner { } invalidate(): void { + for (const [key, entry] of this.entries) { + this.detach(key, entry) + } this.entries.clear() } @@ -62,9 +85,7 @@ export class GitStatusReadLeaseOwner { signal?.removeEventListener('abort', onAbort) entry.liveLeases -= 1 if (abortReason !== undefined && entry.liveLeases === 0 && !entry.settled) { - if (this.entries.get(key) === entry) { - this.entries.delete(key) - } + this.detach(key, entry) entry.controller.abort(abortReason) } return true @@ -77,6 +98,9 @@ export class GitStatusReadLeaseOwner { } signal?.addEventListener('abort', onAbort, { once: true }) + if (signal?.aborted) { + onAbort() + } void entry.promise.then( (value) => { if (release()) { @@ -94,6 +118,14 @@ export class GitStatusReadLeaseOwner { private settle(key: string, entry: StatusReadEntry): void { entry.settled = true + this.detach(key, entry) + } + + private detach(key: string, entry: StatusReadEntry): void { + if (entry.timeout !== undefined) { + clearTimeout(entry.timeout) + entry.timeout = undefined + } if (this.entries.get(key) === entry) { this.entries.delete(key) } diff --git a/src/shared/git-worktree-admin.test.ts b/src/shared/git-worktree-admin.test.ts new file mode 100644 index 00000000000..2ae0ca39ef9 --- /dev/null +++ b/src/shared/git-worktree-admin.test.ts @@ -0,0 +1,119 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { resolveGitCommonDirectory } from './git-common-directory' +import { annotateWorktreeLocksFromAdmin, isBranchInDetachedWorktree } from './git-worktree-admin' +import type { GitWorktreeInfo } from './worktree/types' +import { isWorktreeCreatePreparation } from './worktree/create-preparation' + +let root = '' +let repo = '' +let common = '' +let linked = '' +let admin = '' +const preparationReason = 'orca-create-preparation:v1:12345:lease' + +function rows(): GitWorktreeInfo[] { + return [ + { path: repo, branch: 'refs/heads/main', head: 'abc', isBare: false, isMainWorktree: true }, + { path: linked, branch: '', head: 'abc', isBare: false, isMainWorktree: false } + ] +} + +beforeEach(async () => { + root = await mkdtemp(path.join(tmpdir(), 'orca-admin-safety-')) + repo = path.join(root, 'repo') + common = path.join(repo, '.git') + linked = path.join(root, '.orca-preparing', 'checkout') + admin = path.join(common, 'worktrees', 'checkout') + await mkdir(admin, { recursive: true }) + await mkdir(linked, { recursive: true }) + await writeFile(path.join(common, 'HEAD'), 'ref: refs/heads/main\n') + await writeFile(path.join(admin, 'gitdir'), `${path.join(linked, '.git')}\n`) + await writeFile(path.join(admin, 'commondir'), '../..\n') + await writeFile(path.join(linked, '.git'), `gitdir: ${admin}\r\n`) +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +describe('owning-host worktree administrative reads', () => { + it('resolves normal, linked, separate-git-dir and bare layouts without subprocesses', async () => { + await expect(resolveGitCommonDirectory(repo)).resolves.toBe(common) + await expect(resolveGitCommonDirectory(linked)).resolves.toBe(common) + await expect(resolveGitCommonDirectory(common)).resolves.toBe(common) + const separate = path.join(root, 'separate') + await mkdir(separate) + await writeFile(path.join(separate, '.git'), 'gitdir: ../repo/.git\r\n') + await expect(resolveGitCommonDirectory(separate)).resolves.toBe(common) + }) + + it('recovers exact preparation reasons even when the checkout directory is missing', async () => { + await writeFile(path.join(admin, 'locked'), `${preparationReason}\n`) + await rm(linked, { recursive: true }) + const annotated = await annotateWorktreeLocksFromAdmin(repo, rows()) + expect(annotated[1]).toMatchObject({ locked: true, lockReason: preparationReason }) + expect(isWorktreeCreatePreparation(annotated[1])).toBe(true) + }) + + it('keeps empty and foreign locks without claiming preparations by path shape', async () => { + await writeFile(path.join(admin, 'locked'), '') + const empty = await annotateWorktreeLocksFromAdmin(repo, rows()) + expect(empty[1].locked).toBe(true) + expect(isWorktreeCreatePreparation(empty[1])).toBe(false) + await writeFile(path.join(admin, 'locked'), 'user session\n') + const foreign = await annotateWorktreeLocksFromAdmin(repo, rows()) + expect(foreign[1]).toMatchObject({ locked: true, lockReason: 'user session' }) + expect(isWorktreeCreatePreparation(foreign[1])).toBe(false) + }) + + it('does not treat an unreadable marker as an unlocked authoritative registration', async () => { + await mkdir(path.join(admin, 'locked')) + await expect(annotateWorktreeLocksFromAdmin(repo, rows())).rejects.toThrow() + }) + + it('does not cache a replaced lock reason or another repository’s metadata', async () => { + await writeFile(path.join(admin, 'locked'), `${preparationReason}\n`) + expect((await annotateWorktreeLocksFromAdmin(repo, rows()))[1].lockReason).toBe( + preparationReason + ) + await writeFile(path.join(admin, 'locked'), 'replacement\n') + expect((await annotateWorktreeLocksFromAdmin(repo, rows()))[1].lockReason).toBe('replacement') + const other = path.join(root, 'other') + await mkdir(path.join(other, '.git'), { recursive: true }) + await writeFile(path.join(other, '.git', 'HEAD'), 'ref: refs/heads/main\n') + const otherRows = rows().map((row) => (row.isMainWorktree ? { ...row, path: other } : row)) + expect((await annotateWorktreeLocksFromAdmin(other, otherRows))[1].locked).toBeUndefined() + }) + + it.each(['rebase-merge/head-name', 'rebase-apply/head-name', 'BISECT_START'])( + 'checks detached main worktrees as well as linked ones: %s', + async (marker) => { + const file = path.join(common, ...marker.split('/')) + await mkdir(path.dirname(file), { recursive: true }) + await writeFile(file, 'refs/heads/feature\n') + const detachedMain = rows().map((row) => (row.isMainWorktree ? { ...row, branch: '' } : row)) + await expect(isBranchInDetachedWorktree(repo, 'feature', detachedMain)).resolves.toBe(true) + await expect(isBranchInDetachedWorktree(repo, 'other', detachedMain)).resolves.toBe(false) + } + ) + + it('fails closed when a detached registration cannot be associated with admin metadata', async () => { + await rm(path.join(admin, 'gitdir')) + await expect(isBranchInDetachedWorktree(repo, 'feature', rows())).rejects.toThrow( + 'Cannot verify detached worktree branch usage' + ) + }) + + it('propagates cancellation during administrative reads', async () => { + const signal = AbortSignal.abort(new Error('cancelled')) + await expect(annotateWorktreeLocksFromAdmin(repo, rows(), { signal })).rejects.toThrow( + 'cancelled' + ) + await expect(isBranchInDetachedWorktree(repo, 'feature', rows(), { signal })).rejects.toThrow( + 'cancelled' + ) + }) +}) diff --git a/src/shared/git-worktree-admin.ts b/src/shared/git-worktree-admin.ts new file mode 100644 index 00000000000..c2d7ed266a9 --- /dev/null +++ b/src/shared/git-worktree-admin.ts @@ -0,0 +1,127 @@ +import type { Dirent } from 'node:fs' +import { readdir } from 'node:fs/promises' +import path from 'node:path' +import { waitForPromiseWithSignal } from './abort-signal-reason' +import { + isMissingGitAdminEntry, + readGitAdminFile, + resolveGitCommonDirectory, + type GitAdminReadOptions +} from './git-common-directory' +import { resolveGitMetadataPath, resolveWorktreeHostPath } from './git-metadata-path' +import { mapWithConcurrency } from './map-with-concurrency' +import { foldWslUncPathCaseInsensitiveParts } from './wsl-paths' +import type { GitWorktreeInfo } from './worktree/types' + +const ADMIN_READ_CONCURRENCY = 8 +type WorktreeAdminDirectory = { gitDir: string; worktreePath?: string; isMain?: true } + +function hostPathKey(value: string): string { + const normalized = path.resolve(value) + return ( + foldWslUncPathCaseInsensitiveParts(normalized) ?? + (process.platform === 'win32' ? normalized.toLowerCase() : normalized) + ) +} + +async function readWorktreeAdminDirectories( + repoPath: string, + options: GitAdminReadOptions +): Promise { + const commonDir = await resolveGitCommonDirectory(repoPath, options) + if (!commonDir) { + throw new Error('Cannot read Git worktree administrative directory.') + } + const adminDir = path.join(commonDir, 'worktrees') + let entries: Dirent[] + try { + entries = await waitForPromiseWithSignal( + readdir(adminDir, { withFileTypes: true }), + options.signal + ) + } catch (error) { + if (isMissingGitAdminEntry(error)) { + return [{ gitDir: commonDir, isMain: true }] + } + throw error + } + const linked = await mapWithConcurrency( + entries.filter((entry) => entry.isDirectory()), + ADMIN_READ_CONCURRENCY, + async (entry): Promise => { + const gitDir = path.join(adminDir, entry.name) + const gitdir = await readGitAdminFile(path.join(gitDir, 'gitdir'), options.signal) + const target = gitdir && resolveGitMetadataPath(gitDir, gitdir, options) + return { gitDir, ...(target ? { worktreePath: path.dirname(target) } : {}) } + } + ) + return [{ gitDir: commonDir, isMain: true }, ...linked] +} + +/** Older porcelain omits locks; the marker remains the authoritative ownership proof. */ +export async function annotateWorktreeLocksFromAdmin( + repoPath: string, + worktrees: GitWorktreeInfo[], + options: GitAdminReadOptions = {} +): Promise { + if (!worktrees.some((worktree) => !worktree.isMainWorktree && !worktree.locked)) { + return worktrees + } + const directories = await readWorktreeAdminDirectories(repoPath, options) + const locks = new Map() + await mapWithConcurrency(directories, ADMIN_READ_CONCURRENCY, async (entry) => { + if (!entry.worktreePath) { + return + } + const reason = await readGitAdminFile(path.join(entry.gitDir, 'locked'), options.signal) + if (reason !== null) { + locks.set(hostPathKey(entry.worktreePath), reason.trim()) + } + }) + options.signal?.throwIfAborted() + return worktrees.map((worktree) => { + const hostPath = resolveWorktreeHostPath(worktree.path, options) + const reason = hostPath ? locks.get(hostPathKey(hostPath)) : undefined + return reason === undefined + ? worktree + : { ...worktree, locked: true, ...(reason ? { lockReason: reason } : {}) } + }) +} + +/** Detached HEAD during rebase or bisect still reserves the original branch. */ +export async function isBranchInDetachedWorktree( + repoPath: string, + branchName: string, + worktrees: GitWorktreeInfo[], + options: GitAdminReadOptions = {} +): Promise { + const detached = worktrees.filter((worktree) => !worktree.branch && !worktree.isBare) + if (detached.length === 0) { + return false + } + const targets = new Set( + detached.map((worktree) => { + const hostPath = resolveWorktreeHostPath(worktree.path, options) + return hostPath ? hostPathKey(hostPath) : worktree.path + }) + ) + const directories = await readWorktreeAdminDirectories(repoPath, options) + const relevant = directories.filter((entry) => + entry.isMain + ? detached.some((worktree) => worktree.isMainWorktree) + : entry.worktreePath && targets.has(hostPathKey(entry.worktreePath)) + ) + if (relevant.length < detached.length) { + throw new Error('Cannot verify detached worktree branch usage.') + } + const matches = await mapWithConcurrency(relevant, ADMIN_READ_CONCURRENCY, async (entry) => { + const markers = await Promise.all( + ['rebase-merge/head-name', 'rebase-apply/head-name', 'BISECT_START'].map((name) => + readGitAdminFile(path.join(entry.gitDir, ...name.split('/')), options.signal) + ) + ) + return markers.some((marker) => marker?.trim().replace(/^refs\/heads\//, '') === branchName) + }) + options.signal?.throwIfAborted() + return matches.some(Boolean) +} diff --git a/src/shared/growing-byte-buffer.test.ts b/src/shared/growing-byte-buffer.test.ts index 988d63bb2ae..b39dfd907c8 100644 --- a/src/shared/growing-byte-buffer.test.ts +++ b/src/shared/growing-byte-buffer.test.ts @@ -2,6 +2,20 @@ import { describe, expect, it } from 'vitest' import { GrowingByteBuffer } from './growing-byte-buffer' describe('GrowingByteBuffer', () => { + it('copies live bytes without consuming them or exposing mutable storage', () => { + const buffer = new GrowingByteBuffer() + buffer.append(Buffer.from('prefix-tail')) + buffer.retainSuffix(4) + const snapshot = buffer.toBuffer() + snapshot.fill(0) + expect(buffer.toBuffer()).toEqual(Buffer.from('tail')) + expect(buffer.byteLength).toBe(4) + const retained = buffer.toBuffer() + buffer.appendRetainedSuffix(Buffer.from('next'), 4) + expect(retained).toEqual(Buffer.from('tail')) + expect(buffer.toBuffer()).toEqual(Buffer.from('next')) + }) + it('transfers binary bytes without changing them on clear or reuse', () => { const buffer = new GrowingByteBuffer() const bytes = Buffer.from([0, 255, 128, 10, 0]) diff --git a/src/shared/growing-byte-buffer.ts b/src/shared/growing-byte-buffer.ts index bbcdab4ba30..1837d6fabb6 100644 --- a/src/shared/growing-byte-buffer.ts +++ b/src/shared/growing-byte-buffer.ts @@ -86,6 +86,10 @@ export class GrowingByteBuffer { return this.storage.toString(encoding, this.start, this.start + this.length) } + toBuffer(): Buffer { + return Buffer.from(this.storage.subarray(this.start, this.start + this.length)) + } + takeString(encoding: BufferEncoding = 'utf8'): string { const value = this.toString(encoding) this.clear() diff --git a/src/shared/text-search.ts b/src/shared/text-search.ts index 7bb54243c4d..b6f9e9e6b23 100644 --- a/src/shared/text-search.ts +++ b/src/shared/text-search.ts @@ -172,6 +172,8 @@ export function buildGitGrepArgs(query: string, opts: SearchOptionsLike): string const gitArgs: string[] = [ '-c', 'submodule.recurse=false', + '-c', + 'grep.column=false', 'grep', '-n', '-I', diff --git a/src/shared/worktree/submodule-removal.test.ts b/src/shared/worktree/submodule-removal.test.ts deleted file mode 100644 index 464fc9e7766..00000000000 --- a/src/shared/worktree/submodule-removal.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { isSubmoduleWorktreeRemovalRefusal } from './submodule-removal' - -describe('isSubmoduleWorktreeRemovalRefusal', () => { - it('matches the English git fatal on stderr', () => { - expect( - isSubmoduleWorktreeRemovalRefusal( - Object.assign(new Error('git worktree remove failed'), { - stderr: 'fatal: working trees containing submodules cannot be moved or removed\n' - }) - ) - ).toBe(true) - }) - - it('matches when the refusal is only in the error message', () => { - expect( - isSubmoduleWorktreeRemovalRefusal( - new Error('fatal: working trees containing submodules cannot be moved or removed') - ) - ).toBe(true) - }) - - it('does not match dirty-worktree or lock refusals', () => { - expect( - isSubmoduleWorktreeRemovalRefusal( - Object.assign(new Error('git worktree remove failed'), { - stderr: 'fatal: contains modified or untracked files, use --force to delete it' - }) - ) - ).toBe(false) - expect( - isSubmoduleWorktreeRemovalRefusal( - Object.assign(new Error('git worktree remove failed'), { - stderr: 'fatal: cannot remove a locked working tree' - }) - ) - ).toBe(false) - }) -}) diff --git a/src/shared/worktree/submodule-removal.ts b/src/shared/worktree/submodule-removal.ts deleted file mode 100644 index 2b9306c4650..00000000000 --- a/src/shared/worktree/submodule-removal.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { readGitCommandFailureText } from '../git-command-failure-text' - -// Why: `git worktree remove` (non-force) categorically refuses any worktree -// containing an initialised submodule, even when parent and submodule are -// fully clean (validate_no_submodules, Git >= 2.17). Callers re-prove -// cleanliness and retry with --force. Both the local runner and the relay pin -// English git output (UNTRANSLATED_GIT_OUTPUT_ENV), so text matching is stable. -export function isSubmoduleWorktreeRemovalRefusal(error: unknown): boolean { - return /working trees containing submodules cannot be moved or removed/i.test( - readGitCommandFailureText(error) - ) -} diff --git a/src/shared/wsl-paths.ts b/src/shared/wsl-paths.ts index 1d4b535812a..cc63bc5e318 100644 --- a/src/shared/wsl-paths.ts +++ b/src/shared/wsl-paths.ts @@ -193,3 +193,20 @@ export function getWslFilesystemBoundaryDistro(args: { } return args.wslRuntimeDistro || null } + +/** + * Groups paths by the host that must answer for them, so many dead + * subdirectories of one share share a lane. Returns null for local-disk paths, + * which never block long enough to be worth queueing. + */ +export function uncRouteKey(cwd: string): string | null { + if (!cwd.startsWith('\\\\')) { + return null + } + const wslInfo = parseWslUncPath(cwd) + if (wslInfo) { + return `wsl:${wslInfo.distro.trim().toLowerCase()}` + } + const server = cwd.slice(2).split(/[\\/]/, 1)[0] + return `unc:${server.toLowerCase()}` +}