fix: treat git stage paths as literals

This commit is contained in:
Neil 2026-05-30 14:27:17 -07:00 committed by GitHub
parent 5f84e6d5b5
commit 350adb33ba
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 88 additions and 7 deletions

View File

@ -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<string> {
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'])
})
})

View File

@ -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'
}

View File

@ -1020,14 +1020,16 @@ const PREVIEWABLE_BINARY_MIME_TYPES: Record<string, string> = {
* Stage a file.
*/
export async function stageFile(worktreePath: string, filePath: string): Promise<void> {
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<void> {
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
})
}
}