diff --git a/src/relay/git-handler-staging.test.ts b/src/relay/git-handler-staging.test.ts index d4469f37cda..b113afd30c4 100644 --- a/src/relay/git-handler-staging.test.ts +++ b/src/relay/git-handler-staging.test.ts @@ -1,5 +1,5 @@ /** - * Tests for GitHandler commit and bulk-staging operations. + * Tests for GitHandler commit and staging operations. * * Why: split from git-handler.test.ts to stay under the oxlint max-lines (300) limit. */ @@ -19,6 +19,32 @@ import { type RelayDispatcher } from './git-handler-test-setup' +const PATHSPEC_SELECTED_FILE = '[k]eep.log' +const PATHSPEC_MATCHING_FILE = 'keep.log' +const PATHSPEC_MUTATION_CASES = [ + { + mode: 'single-file', + stageMethod: 'git.stage', + unstageMethod: 'git.unstage', + selection: { filePath: PATHSPEC_SELECTED_FILE } + }, + { + mode: 'bulk', + stageMethod: 'git.bulkStage', + unstageMethod: 'git.bulkUnstage', + selection: { filePaths: [PATHSPEC_SELECTED_FILE] } + } +] as const + +function createPathspecCollisionChanges(dir: string): void { + gitInit(dir) + writeFileSync(path.join(dir, PATHSPEC_SELECTED_FILE), 'selected') + writeFileSync(path.join(dir, PATHSPEC_MATCHING_FILE), 'matching') + gitCommit(dir, 'initial') + writeFileSync(path.join(dir, PATHSPEC_SELECTED_FILE), 'selected modified') + writeFileSync(path.join(dir, PATHSPEC_MATCHING_FILE), 'matching modified') +} + describe('GitHandler — commit & staging', () => { let dispatcher: MockDispatcher let tmpDir: string @@ -77,7 +103,38 @@ describe('GitHandler — commit & staging', () => { }) }) - describe('bulkStage and bulkUnstage', () => { + describe('stage and unstage', () => { + it.each(PATHSPEC_MUTATION_CASES)( + 'treats $mode stage paths with Git glob characters as literals', + async ({ stageMethod, selection }) => { + createPathspecCollisionChanges(tmpDir) + + await dispatcher.callRequest(stageMethod, { worktreePath: tmpDir, ...selection }) + + const output = execFileSync('git', ['diff', '--cached', '--name-only'], { + cwd: tmpDir, + encoding: 'utf-8' + }) + expect(output.trim()).toBe(PATHSPEC_SELECTED_FILE) + } + ) + + it.each(PATHSPEC_MUTATION_CASES)( + 'treats $mode unstage paths with Git glob characters as literals', + async ({ unstageMethod, selection }) => { + createPathspecCollisionChanges(tmpDir) + execFileSync('git', ['add', '.'], { cwd: tmpDir, stdio: 'pipe' }) + + await dispatcher.callRequest(unstageMethod, { worktreePath: tmpDir, ...selection }) + + const output = execFileSync('git', ['diff', '--cached', '--name-only'], { + cwd: tmpDir, + encoding: 'utf-8' + }) + expect(output.trim()).toBe(PATHSPEC_MATCHING_FILE) + } + ) + it('stages multiple files', async () => { gitInit(tmpDir) writeFileSync(path.join(tmpDir, 'a.txt'), 'a') diff --git a/src/relay/git-handler.test.ts b/src/relay/git-handler.test.ts index 1ae68d3ce88..d674af9949e 100644 --- a/src/relay/git-handler.test.ts +++ b/src/relay/git-handler.test.ts @@ -1192,7 +1192,7 @@ describe('GitHandler', () => { await Promise.all([first, second]) expect(gitBufferSpy).toHaveBeenCalledTimes(2) - expect(gitSpy).toHaveBeenCalledWith(['add', '--', 'src/file.ts'], tmpDir) + expect(gitSpy).toHaveBeenCalledWith(['add', '--', ':(literal)src/file.ts'], tmpDir) const submodulePathReads = gitSpy.mock.calls.filter( ([args]) => args[0] === 'config' && args.includes('.gitmodules') ) diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index d21dcf3cce9..c6709cd3f2a 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -488,7 +488,7 @@ export class GitHandler { const worktreePath = params.worktreePath as string const filePath = params.filePath as string try { - await this.git(['add', '--', filePath], worktreePath) + await this.git(['add', '--', this.literalPathspec(filePath)], worktreePath) } finally { this.clearGitMutationReadCaches() } @@ -512,7 +512,7 @@ export class GitHandler { const worktreePath = params.worktreePath as string const filePath = params.filePath as string try { - await this.git(['restore', '--staged', '--', filePath], worktreePath) + await this.git(['restore', '--staged', '--', this.literalPathspec(filePath)], worktreePath) } finally { this.clearGitMutationReadCaches() } @@ -525,7 +525,10 @@ export class GitHandler { 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], worktreePath) + await this.git( + ['add', '--', ...chunk.map((filePath) => this.literalPathspec(filePath))], + worktreePath + ) } } finally { this.clearGitMutationReadCaches() @@ -539,7 +542,10 @@ export class GitHandler { 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], worktreePath) + await this.git( + ['restore', '--staged', '--', ...chunk.map((filePath) => this.literalPathspec(filePath))], + worktreePath + ) } } finally { this.clearGitMutationReadCaches()