From 350adb33ba977ea3d4bcc1bf2e5c363413279660 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 30 May 2026 14:27:17 -0700 Subject: [PATCH] fix: treat git stage paths as literals --- src/main/git/status-pathspec-literals.test.ts | 72 +++++++++++++++++++ src/main/git/status.test.ts | 11 ++- src/main/git/status.ts | 12 ++-- 3 files changed, 88 insertions(+), 7 deletions(-) create mode 100644 src/main/git/status-pathspec-literals.test.ts diff --git a/src/main/git/status-pathspec-literals.test.ts b/src/main/git/status-pathspec-literals.test.ts new file mode 100644 index 000000000..31d66bcdb --- /dev/null +++ b/src/main/git/status-pathspec-literals.test.ts @@ -0,0 +1,72 @@ +import { execFileSync } from 'child_process' +import { mkdtemp, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import * as path from 'path' +import { afterEach, describe, expect, it } from 'vitest' +import { bulkStageFiles, bulkUnstageFiles, stageFile, unstageFile } from './status' + +const tempRoots: string[] = [] + +async function createRepoWithGlobNamedFiles(): Promise { + const repo = await mkdtemp(path.join(tmpdir(), 'orca-status-pathspec-')) + tempRoots.push(repo) + execFileSync('git', ['init', '-q'], { cwd: repo }) + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repo }) + execFileSync('git', ['config', 'user.name', 'Test User'], { cwd: repo }) + await writeFile(path.join(repo, '*.log'), 'selected') + await writeFile(path.join(repo, 'keep.log'), 'keep') + execFileSync('git', ['add', '*.log', 'keep.log'], { cwd: repo }) + execFileSync('git', ['commit', '-q', '-m', 'initial'], { cwd: repo }) + await writeFile(path.join(repo, '*.log'), 'selected modified') + await writeFile(path.join(repo, 'keep.log'), 'keep modified') + return repo +} + +function gitNames(repo: string, args: string[]): string[] { + const stdout = execFileSync('git', args, { cwd: repo, encoding: 'utf8' }) + return stdout.split(/\r?\n/).filter(Boolean) +} + +afterEach(async () => { + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('git status pathspec literals', () => { + it('stages a tracked path with Git glob characters as one literal path', async () => { + const repo = await createRepoWithGlobNamedFiles() + + await stageFile(repo, '*.log') + + expect(gitNames(repo, ['diff', '--cached', '--name-only'])).toEqual(['*.log']) + expect(gitNames(repo, ['diff', '--name-only'])).toEqual(['keep.log']) + }) + + it('bulk stages tracked paths with Git glob characters as literal paths', async () => { + const repo = await createRepoWithGlobNamedFiles() + + await bulkStageFiles(repo, ['*.log']) + + expect(gitNames(repo, ['diff', '--cached', '--name-only'])).toEqual(['*.log']) + expect(gitNames(repo, ['diff', '--name-only'])).toEqual(['keep.log']) + }) + + it('unstages a tracked path with Git glob characters as one literal path', async () => { + const repo = await createRepoWithGlobNamedFiles() + execFileSync('git', ['add', '*.log', 'keep.log'], { cwd: repo }) + + await unstageFile(repo, '*.log') + + expect(gitNames(repo, ['diff', '--cached', '--name-only'])).toEqual(['keep.log']) + expect(gitNames(repo, ['diff', '--name-only'])).toEqual(['*.log']) + }) + + it('bulk unstages tracked paths with Git glob characters as literal paths', async () => { + const repo = await createRepoWithGlobNamedFiles() + execFileSync('git', ['add', '*.log', 'keep.log'], { cwd: repo }) + + await bulkUnstageFiles(repo, ['*.log']) + + expect(gitNames(repo, ['diff', '--cached', '--name-only'])).toEqual(['keep.log']) + expect(gitNames(repo, ['diff', '--name-only'])).toEqual(['*.log']) + }) +}) diff --git a/src/main/git/status.test.ts b/src/main/git/status.test.ts index e8d1aac82..618ef359d 100644 --- a/src/main/git/status.test.ts +++ b/src/main/git/status.test.ts @@ -139,14 +139,14 @@ describe('bulk git helpers', () => { expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(3) expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith( 1, - ['add', '--', ...filePaths.slice(0, 100)], + ['add', '--', ...filePaths.slice(0, 100).map((filePath) => `:(literal)${filePath}`)], { cwd: '/repo' } ) expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith( 3, - ['add', '--', ...filePaths.slice(200)], + ['add', '--', ...filePaths.slice(200).map((filePath) => `:(literal)${filePath}`)], { cwd: '/repo' } @@ -162,7 +162,12 @@ describe('bulk git helpers', () => { expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith( 2, - ['restore', '--staged', '--', ...filePaths.slice(100)], + [ + 'restore', + '--staged', + '--', + ...filePaths.slice(100).map((filePath) => `:(literal)${filePath}`) + ], { cwd: '/repo' } diff --git a/src/main/git/status.ts b/src/main/git/status.ts index a72c91419..10b501f3e 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -1020,14 +1020,16 @@ const PREVIEWABLE_BINARY_MIME_TYPES: Record = { * Stage a file. */ export async function stageFile(worktreePath: string, filePath: string): Promise { - await gitExecFileAsync(['add', '--', filePath], { cwd: worktreePath }) + await gitExecFileAsync(['add', '--', literalPathspec(filePath)], { cwd: worktreePath }) } /** * Unstage a file. */ export async function unstageFile(worktreePath: string, filePath: string): Promise { - await gitExecFileAsync(['restore', '--staged', '--', filePath], { cwd: worktreePath }) + await gitExecFileAsync(['restore', '--staged', '--', literalPathspec(filePath)], { + cwd: worktreePath + }) } export async function getStagedCommitContext( @@ -1246,7 +1248,7 @@ export async function bulkStageFiles(worktreePath: string, filePaths: string[]): } for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) { const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) - await gitExecFileAsync(['add', '--', ...chunk], { cwd: worktreePath }) + await gitExecFileAsync(['add', '--', ...chunk.map(literalPathspec)], { cwd: worktreePath }) } } @@ -1259,6 +1261,8 @@ export async function bulkUnstageFiles(worktreePath: string, filePaths: string[] } for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) { const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) - await gitExecFileAsync(['restore', '--staged', '--', ...chunk], { cwd: worktreePath }) + await gitExecFileAsync(['restore', '--staged', '--', ...chunk.map(literalPathspec)], { + cwd: worktreePath + }) } }