fix(relay): treat staging paths as literals (#8358)

* fix(relay): treat staging paths as literals

* docs(relay): document pathspec collision fixture

* docs(relay): document staging mutation contracts

* chore(relay): remove redundant staging comments

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Siddharth Ahire <siddharth@Siddharths-MacBook-Air.local>
Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Sidd
2026-07-12 21:04:13 -07:00
committed by GitHub
co-authored by Orca Siddharth Ahire Jinwoo-H
parent 8abe093e86
commit deb8152c9a
3 changed files with 70 additions and 7 deletions
+59 -2
View File
@@ -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')
+1 -1
View File
@@ -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')
)
+10 -4
View File
@@ -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()