Skip to content

Commit f3abe29

Browse files
committed
fix(worktree): handle double-quoted escapes in git config value parsing
1 parent e8f33a6 commit f3abe29

2 files changed

Lines changed: 105 additions & 39 deletions

File tree

src/core/worktree.ts

Lines changed: 55 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from 'node:fs';
2-
import { dirname, isAbsolute, join, parse as parsePath, resolve, sep } from 'node:path';
2+
import { dirname, isAbsolute, join, resolve } from 'node:path';
3+
import { resolveChdirTarget } from '@/core/path';
34

45
export const GIT_GLOBAL_OPTS_WITH_VALUE: ReadonlySet<string> = new Set([
56
'-c',
@@ -18,6 +19,14 @@ export const GIT_CONTEXT_ENV_OVERRIDES = [
1819
'GIT_INDEX_FILE',
1920
] as const;
2021

22+
export const GIT_CONFIG_AFFECTING_ENV_NAMES: ReadonlySet<string> = new Set([
23+
'GIT_CONFIG_GLOBAL',
24+
'GIT_CONFIG_NOSYSTEM',
25+
'GIT_CONFIG_SYSTEM',
26+
'HOME',
27+
'XDG_CONFIG_HOME',
28+
]);
29+
2130
export interface GitExecutionContext {
2231
gitCwd: string | null;
2332
hasExplicitGitContext: boolean;
@@ -246,62 +255,66 @@ function readCoreWorktree(configPath: string): string | null {
246255

247256
const match = trimmed.match(/^worktree\s*=\s*(.*)$/i);
248257
if (match) {
249-
configuredWorktree = unquoteGitConfigValue(match[1] ?? '');
258+
configuredWorktree = parseGitConfigValue(match[1] ?? '');
250259
}
251260
}
252261

253262
return configuredWorktree;
254263
}
255264

256-
function unquoteGitConfigValue(value: string): string {
265+
function parseGitConfigValue(value: string): string {
257266
const trimmed = value.trim();
258-
if (
259-
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
260-
(trimmed.startsWith("'") && trimmed.endsWith("'"))
261-
) {
262-
return trimmed.slice(1, -1);
267+
if (!trimmed.startsWith('"') || !trimmed.endsWith('"')) {
268+
return trimmed;
263269
}
264-
return trimmed;
270+
return unescapeDoubleQuotedGitConfigValue(trimmed.slice(1, -1));
265271
}
266272

267-
function resolveGitCwd(baseCwd: string, target: string): string | null {
268-
try {
269-
const resolved = resolveChdirTarget(baseCwd, target);
270-
return isDirectory(resolved) ? resolved : null;
271-
} catch {
272-
return null;
273-
}
274-
}
275-
276-
function resolveChdirTarget(baseCwd: string, target: string): string {
277-
const root = isAbsolute(target) ? getPathRoot(target) : '';
278-
let current = root || baseCwd;
279-
for (const component of getPathComponents(root ? target.slice(root.length) : target)) {
280-
if (component === '' || component === '.') {
273+
function unescapeDoubleQuotedGitConfigValue(value: string): string {
274+
let result = '';
275+
for (let i = 0; i < value.length; i++) {
276+
const char = value[i];
277+
if (char !== '\\') {
278+
result += char;
281279
continue;
282280
}
283-
if (component === '..') {
284-
current = dirname(current);
281+
282+
const next = value[i + 1];
283+
if (next === undefined) {
284+
result += char;
285285
continue;
286286
}
287287

288-
const candidate = appendPathWithoutNormalizing(current, component);
289-
current = lstatSync(candidate).isSymbolicLink() ? realpathSync(candidate) : candidate;
288+
switch (next) {
289+
case '\\':
290+
case '"':
291+
result += next;
292+
break;
293+
case 'n':
294+
result += '\n';
295+
break;
296+
case 't':
297+
result += '\t';
298+
break;
299+
case 'b':
300+
result += '\b';
301+
break;
302+
default:
303+
result += `\\${next}`;
304+
break;
305+
}
306+
i++;
290307
}
291-
return current;
308+
return result;
292309
}
293310

294-
function appendPathWithoutNormalizing(base: string, target: string): string {
295-
return base.endsWith('/') || base.endsWith('\\') ? `${base}${target}` : `${base}${sep}${target}`;
296-
}
297-
298-
function getPathRoot(target: string): string {
299-
return parsePath(target).root;
300-
}
301-
302-
function getPathComponents(target: string): string[] {
303-
const separator = process.platform === 'win32' ? /[\\/]+/ : /\/+/;
304-
return target.split(separator);
311+
function resolveGitCwd(baseCwd: string, target: string): string | null {
312+
try {
313+
const resolved = resolveChdirTarget(baseCwd, target);
314+
return isDirectory(resolved) ? resolved : null;
315+
} catch {
316+
return null;
317+
}
305318
}
306319

307320
function isDirectory(path: string): boolean {
@@ -333,3 +346,6 @@ function findDotGit(cwd: string): string | null {
333346
current = parent;
334347
}
335348
}
349+
350+
/** @internal Exported for testing */
351+
export { parseGitConfigValue as _parseGitConfigValue };

tests/core/worktree.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, expect, test } from 'bun:test';
2+
import { execFileSync } from 'node:child_process';
23
import {
34
mkdirSync,
45
mkdtempSync,
@@ -11,6 +12,7 @@ import {
1112
import { tmpdir } from 'node:os';
1213
import { dirname, isAbsolute, join, resolve } from 'node:path';
1314
import {
15+
_parseGitConfigValue,
1416
getGitExecutionContext,
1517
hasGitContextEnvOverride,
1618
isLinkedWorktree,
@@ -181,6 +183,16 @@ describe('worktree env context overrides', () => {
181183
});
182184

183185
describe('linked worktree detection', () => {
186+
test('parses double-quoted git config escapes', () => {
187+
expect(_parseGitConfigValue('"/tmp/with \\"quotes\\""')).toBe('/tmp/with "quotes"');
188+
expect(_parseGitConfigValue('"/tmp/backslash\\\\path"')).toBe('/tmp/backslash\\path');
189+
expect(_parseGitConfigValue('"line\\nfeed\\ttab\\bbackspace"')).toBe(
190+
'line\nfeed\ttab\bbackspace',
191+
);
192+
expect(_parseGitConfigValue('"unknown\\xpath"')).toBe('unknown\\xpath');
193+
expect(_parseGitConfigValue("'/tmp/single'")).toBe("'/tmp/single'");
194+
});
195+
184196
test('normalizes Windows native realpath prefixes for comparison', () => {
185197
expect(normalizePathForComparison('\\\\?\\C:\\Temp\\Linked\\.git\\')).toBe(
186198
process.platform === 'win32' ? 'c:/temp/linked/.git' : 'C:/Temp/Linked/.git',
@@ -273,4 +285,42 @@ describe('linked worktree detection', () => {
273285
fixture.cleanup();
274286
}
275287
});
288+
289+
test.skipIf(process.platform === 'win32')(
290+
'accepts double-quoted escaped core.worktree values',
291+
() => {
292+
const fixture = createLinkedWorktreeFixture();
293+
const quotedWorktree = join(fixture.rootDir, 'linked"quoted');
294+
execFileSync(
295+
'git',
296+
['worktree', 'add', '-b', 'feature/quoted-worktree-test', quotedWorktree],
297+
{
298+
cwd: fixture.mainWorktree,
299+
stdio: 'ignore',
300+
},
301+
);
302+
const gitDir = getLinkedGitDir(quotedWorktree);
303+
const escapedWorktree = quotedWorktree.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
304+
writeFileSync(join(gitDir, 'config.worktree'), `[core]\n\tworktree = "${escapedWorktree}"\n`);
305+
try {
306+
expect(isLinkedWorktree(quotedWorktree)).toBe(true);
307+
} finally {
308+
fixture.cleanup();
309+
}
310+
},
311+
);
312+
313+
test('treats single-quoted core.worktree values as literal paths', () => {
314+
const fixture = createLinkedWorktreeFixture();
315+
const gitDir = getLinkedGitDir(fixture.linkedWorktree);
316+
writeFileSync(
317+
join(gitDir, 'config.worktree'),
318+
`[core]\n\tworktree = '${fixture.linkedWorktree}'\n`,
319+
);
320+
try {
321+
expect(isLinkedWorktree(fixture.linkedWorktree)).toBe(false);
322+
} finally {
323+
fixture.cleanup();
324+
}
325+
});
276326
});

0 commit comments

Comments
 (0)