fix(git): enable core.longpaths for worktree population (#2746) (#2747)

* fix(git): enable core.longpaths for worktree population

Worktrees live under a deep OpenCode data-dir path, so Windows checkouts
of deeply nested repos failed bootstrap with "Filename too long". Enable
Git core.longpaths before git reset --hard (web + VS Code) and surface
clearer path-length guidance when the filesystem still rejects a path.

Fixes #2746

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* chore(vscode): keep ensureWorktreeLongpaths private

Avoid an unused export in the VS Code git service; the helper stays
local to populateWorktreeWithLockRecovery.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
This commit is contained in:
Serhii Dziupin
2026-08-07 09:52:48 +03:00
committed by GitHub
co-authored by Serhii Dziupin Cursor Agent
parent 87dbc59bf1
commit 10606d79d3
5 changed files with 301 additions and 14 deletions
+1
View File
@@ -25,6 +25,7 @@ Keep `bridge.ts` as a thin orchestration layer that delegates message handling t
- Owns VS Code Git and worktree operations.
- Fast worktree creation reports bootstrap phases explicitly: `directory-created`, then `git-ready` after Git population/upstream work, and `setup-ready` after setup commands. Existing worktrees without tracked bootstrap state fall back to `ready`/`setup-ready`; shared webview consumers also accept legacy responses without `phase`.
- Worktree removal waits for an active create/bootstrap task for the same directory so background Git and setup work cannot race deletion or restore stale bootstrap state.
- Worktree population enables Git `core.longpaths` (local repo config plus `-c core.longpaths=true` on `git reset --hard`) so deeply nested checkouts under the managed data-dir worktree root do not fail on Windows MAX_PATH with "Filename too long".
- `bridge-fs-runtime.ts`
- Bridge handlers for filesystem-related message routes.
+47 -7
View File
@@ -1117,34 +1117,71 @@ const getFileIdentity = async (filePath: string): Promise<string | null> => {
}
};
// OpenChamber places managed worktrees under a deep data-dir path
// (`<XDG_DATA_HOME>/opencode/worktree/<40-char project id>/<name>/`). On
// Windows that prefix plus a deeply nested repo file routinely exceeds
// MAX_PATH (260). Git can check those paths out when core.longpaths is
// enabled; without it, `git reset --hard` during bootstrap fails with
// "Filename too long" and leaves a half-populated worktree (issue #2746).
const WORKTREE_POPULATE_RESET_ARGS = ['-c', 'core.longpaths=true', 'reset', '--hard'] as const;
const isFilenameTooLongError = (message: string | null | undefined): boolean =>
/file ?name too long/i.test(String(message || ''));
const formatWorktreePopulateError = (message: string | null | undefined): string => {
const text = String(message || '').trim() || 'Failed to populate worktree';
if (!isFilenameTooLongError(text)) {
return text;
}
return [
text,
'The worktree checkout path exceeds this system\'s path-length limit.',
'OpenChamber enables Git `core.longpaths` for worktree population; if this still fails on Windows, enable OS long paths (LongPathsEnabled) or open the repository from a shorter absolute path.',
].join('\n');
};
const ensureWorktreeLongpaths = async (directory: string): Promise<void> => {
const current = await runGitCommand(directory, ['config', '--get', 'core.longpaths']);
if (String(current.stdout || '').trim().toLowerCase() === 'true') {
return;
}
// Local config is shared across linked worktrees via the common git dir, so
// subsequent OpenChamber and CLI git operations in this repo also get long
// path support. Failures here are non-fatal: populate still passes
// `-c core.longpaths=true` on reset.
await runGitCommand(directory, ['config', 'core.longpaths', 'true']);
};
const populateWorktreeWithLockRecovery = async (directory: string): Promise<void> => {
let result = await runGitCommand(directory, ['reset', '--hard']);
await ensureWorktreeLongpaths(directory);
let result = await runGitCommand(directory, [...WORKTREE_POPULATE_RESET_ARGS]);
if (result.success) {
return;
}
if (!isIndexLockError(result)) {
throw new Error(result.message || 'Failed to populate worktree');
throw new Error(formatWorktreePopulateError(result.message));
}
await wait(WORKTREE_INDEX_LOCK_RETRY_DELAY_MS);
result = await runGitCommand(directory, ['reset', '--hard']);
result = await runGitCommand(directory, [...WORKTREE_POPULATE_RESET_ARGS]);
if (result.success) {
return;
}
if (!isIndexLockError(result)) {
throw new Error(result.message || 'Failed to populate worktree');
throw new Error(formatWorktreePopulateError(result.message));
}
const lockPath = await getWorktreeIndexLockPath(directory);
const identity = lockPath ? await getFileIdentity(lockPath) : null;
await wait(WORKTREE_INDEX_LOCK_STALE_DELAY_MS);
result = await runGitCommand(directory, ['reset', '--hard']);
result = await runGitCommand(directory, [...WORKTREE_POPULATE_RESET_ARGS]);
if (result.success) {
return;
}
if (!isIndexLockError(result) || !lockPath || !identity || await getFileIdentity(lockPath) !== identity) {
throw new Error(result.message || 'Failed to populate worktree');
throw new Error(formatWorktreePopulateError(result.message));
}
await fs.promises.unlink(lockPath).catch((error) => {
@@ -1152,7 +1189,10 @@ const populateWorktreeWithLockRecovery = async (directory: string): Promise<void
throw error;
}
});
await runGitCommandOrThrow(directory, ['reset', '--hard'], 'Failed to populate worktree');
const finalResult = await runGitCommand(directory, [...WORKTREE_POPULATE_RESET_ARGS]);
if (!finalResult.success) {
throw new Error(formatWorktreePopulateError(finalResult.message || 'Failed to populate worktree'));
}
};
// Worktrees are created with `git worktree add --no-checkout` and populated
@@ -136,6 +136,7 @@ The following functions are internal helpers used by exported functions:
- Fast-create background failures remove OpenCode sandbox metadata for directories that never became Git worktrees, and remove the pre-created directory only if it is still empty. User-created files are never recursively deleted by this cleanup.
- Worktree removal waits for any active create/bootstrap task for that directory before deleting it, preventing a background Git or setup task from restoring removed state or racing filesystem cleanup.
- Worktree bootstrap retries transient `index.lock` conflicts. If the lock remains byte-for-byte and metadata-identical across the retry window, it is treated as stale, removed, and population continues automatically; changing locks are left untouched and reported as failures.
- Worktree population enables Git `core.longpaths` (local repo config plus `-c core.longpaths=true` on `git reset --hard`) so deeply nested checkouts under the managed data-dir worktree root do not fail on Windows MAX_PATH with "Filename too long". Path-component limits that the filesystem itself rejects still fail bootstrap, with a clearer path-length guidance message.
### Log Response
- `all`: Array of commit objects with hash, date, message, author info, stats.
@@ -0,0 +1,206 @@
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import {
createWorktree,
ensureWorktreeLongpaths,
getWorktreeBootstrapStatus,
populateWorktreeWithLockRecovery,
} from './service.js';
// ---------------------------------------------------------------------------
// Regression for https://github.com/openchamber/openchamber/issues/2746
//
// "[Bug] new worktree Filename too long"
//
// OpenChamber places worktrees under:
// <XDG_DATA_HOME>/opencode/worktree/<40-char root commit hash>/<worktree name>
// and populates them with `git reset --hard`. On Windows, that deep prefix plus
// a deeply nested repo file (e.g. yudao ~173 chars) exceeds MAX_PATH (260) and
// git aborts with "Filename too long" unless `core.longpaths` is enabled.
// ---------------------------------------------------------------------------
const tempDirs = [];
const createTempDir = () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-git-issue2746-'));
tempDirs.push(dir);
return dir;
};
const runGit = (cwd, args, input) =>
execFileSync('git', args, {
cwd,
encoding: 'utf8',
input,
stdio: ['pipe', 'pipe', 'pipe'],
});
const canRunGit = () => {
try {
execFileSync('git', ['--version'], { stdio: 'ignore' });
return true;
} catch {
return false;
}
};
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
describe('issue #2746 - worktree long path support', () => {
it('enables core.longpaths and populates a deeply nested worktree checkout', async () => {
if (!canRunGit()) return;
const previousXdgDataHome = process.env.XDG_DATA_HOME;
const dataHome = createTempDir();
process.env.XDG_DATA_HOME = dataHome;
try {
const repo = createTempDir();
runGit(repo, ['init', '-b', 'main']);
runGit(repo, ['config', 'user.email', 'test@example.com']);
runGit(repo, ['config', 'user.name', 'Test User']);
// Realistic reporter path: many nested segments, each component well under
// NAME_MAX. On Windows the managed worktree prefix + this relative path
// exceeds MAX_PATH unless core.longpaths is enabled.
const deepRelative = path.join(
'server',
'yudao-framework',
'yudao-spring-boot-starter-biz-data-permission',
'src',
'main',
'java',
'cn',
'iocoder',
'yudao',
'framework',
'datapermission',
'config',
'YudaoDataPermissionAutoConfiguration.java',
);
fs.mkdirSync(path.dirname(path.join(repo, deepRelative)), { recursive: true });
fs.writeFileSync(path.join(repo, deepRelative), '// yudao\n');
fs.writeFileSync(path.join(repo, 'README.md'), '# Test\n');
runGit(repo, ['add', 'README.md', deepRelative]);
runGit(repo, ['commit', '-qm', 'init']);
const created = await createWorktree(repo, {
mode: 'new',
worktreeName: 'issue-2746',
branchName: 'openchamber/issue-2746',
});
expect(created.directoryCreated).toBe(true);
await expect.poll(async () => {
const status = await getWorktreeBootstrapStatus(created.path);
return status?.status;
}, { timeout: 10_000 }).toBe('ready');
const longpaths = runGit(created.path, ['config', '--get', 'core.longpaths']).trim();
expect(longpaths).toBe('true');
expect(fs.existsSync(path.join(created.path, deepRelative))).toBe(true);
expect(fs.existsSync(path.join(created.path, 'README.md'))).toBe(true);
} finally {
if (previousXdgDataHome === undefined) {
delete process.env.XDG_DATA_HOME;
} else {
process.env.XDG_DATA_HOME = previousXdgDataHome;
}
}
});
it('ensureWorktreeLongpaths is idempotent when already enabled', async () => {
if (!canRunGit()) return;
const repo = createTempDir();
runGit(repo, ['init', '-b', 'main']);
runGit(repo, ['config', 'user.email', 'test@example.com']);
runGit(repo, ['config', 'user.name', 'Test User']);
fs.writeFileSync(path.join(repo, 'README.md'), '# Test\n');
runGit(repo, ['add', 'README.md']);
runGit(repo, ['commit', '-qm', 'init']);
runGit(repo, ['config', 'core.longpaths', 'true']);
await expect(ensureWorktreeLongpaths(repo)).resolves.toBeUndefined();
expect(runGit(repo, ['config', '--get', 'core.longpaths']).trim()).toBe('true');
});
it('surfaces guided bootstrap failure when a path component exceeds the filesystem name limit', async () => {
if (!canRunGit()) return;
const previousXdgDataHome = process.env.XDG_DATA_HOME;
const dataHome = createTempDir();
process.env.XDG_DATA_HOME = dataHome;
try {
const repo = createTempDir();
runGit(repo, ['init', '-b', 'main']);
runGit(repo, ['config', 'user.email', 'test@example.com']);
runGit(repo, ['config', 'user.name', 'Test User']);
// Linux/macOS NAME_MAX equivalent of the Windows failure mode: a single
// path component longer than 255 cannot be materialized. core.longpaths
// cannot fix this; bootstrap must fail clearly instead of leaving a
// silent half-populated worktree.
const longComponent = 'x'.repeat(300);
const longPath = `server/${longComponent}/YudaoDataPermissionAutoConfiguration.java`;
const blobHash = runGit(repo, ['hash-object', '-w', '--stdin'], '// test\n').trim();
runGit(repo, ['update-index', '--add', '--cacheinfo', `100644,${blobHash},${longPath}`]);
runGit(repo, ['commit', '-qm', 'init']);
fs.writeFileSync(path.join(repo, 'README.md'), '# Test\n');
runGit(repo, ['add', 'README.md']);
runGit(repo, ['commit', '-qm', 'add readme']);
const created = await createWorktree(repo, {
mode: 'new',
worktreeName: 'issue-2746-namemax',
branchName: 'openchamber/issue-2746-namemax',
});
expect(created.directoryCreated).toBe(true);
await expect.poll(async () => {
const status = await getWorktreeBootstrapStatus(created.path);
return status?.status;
}, { timeout: 10_000 }).toBe('failed');
const status = await getWorktreeBootstrapStatus(created.path);
expect(status?.error).toMatch(/file name too long|filename too long/i);
expect(status?.error).toMatch(/path-length limit/i);
expect(runGit(created.path, ['config', '--get', 'core.longpaths']).trim()).toBe('true');
} finally {
if (previousXdgDataHome === undefined) {
delete process.env.XDG_DATA_HOME;
} else {
process.env.XDG_DATA_HOME = previousXdgDataHome;
}
}
});
it('populateWorktreeWithLockRecovery enables longpaths before reset', async () => {
if (!canRunGit()) return;
const repo = createTempDir();
runGit(repo, ['init', '-b', 'main']);
runGit(repo, ['config', 'user.email', 'test@example.com']);
runGit(repo, ['config', 'user.name', 'Test User']);
fs.writeFileSync(path.join(repo, 'README.md'), '# Test\n');
runGit(repo, ['add', 'README.md']);
runGit(repo, ['commit', '-qm', 'init']);
const worktree = createTempDir();
fs.rmSync(worktree, { recursive: true, force: true });
runGit(repo, ['worktree', 'add', '--no-checkout', '-b', 'feature/longpaths-populate', worktree, 'HEAD']);
await expect(populateWorktreeWithLockRecovery(worktree)).resolves.toBeUndefined();
expect(runGit(worktree, ['config', '--get', 'core.longpaths']).trim()).toBe('true');
expect(fs.readFileSync(path.join(worktree, 'README.md'), 'utf8')).toBe('# Test\n');
});
});
+46 -7
View File
@@ -991,34 +991,70 @@ const getFileIdentity = async (filePath) => {
}
};
// OpenChamber places managed worktrees under a deep data-dir path
// (`<XDG_DATA_HOME>/opencode/worktree/<40-char project id>/<name>/`). On
// Windows that prefix plus a deeply nested repo file routinely exceeds
// MAX_PATH (260). Git can check those paths out when core.longpaths is
// enabled; without it, `git reset --hard` during bootstrap fails with
// "Filename too long" and leaves a half-populated worktree (issue #2746).
const WORKTREE_POPULATE_RESET_ARGS = ['-c', 'core.longpaths=true', 'reset', '--hard'];
const isFilenameTooLongError = (message) => /file ?name too long/i.test(String(message || ''));
const formatWorktreePopulateError = (message) => {
const text = String(message || '').trim() || 'Failed to populate worktree';
if (!isFilenameTooLongError(text)) {
return text;
}
return [
text,
'The worktree checkout path exceeds this system\'s path-length limit.',
'OpenChamber enables Git `core.longpaths` for worktree population; if this still fails on Windows, enable OS long paths (LongPathsEnabled) or open the repository from a shorter absolute path.',
].join('\n');
};
export const ensureWorktreeLongpaths = async (directory) => {
const current = await runGitCommand(directory, ['config', '--get', 'core.longpaths']);
if (String(current.stdout || '').trim().toLowerCase() === 'true') {
return;
}
// Local config is shared across linked worktrees via the common git dir, so
// subsequent OpenChamber and CLI git operations in this repo also get long
// path support. Failures here are non-fatal: populate still passes
// `-c core.longpaths=true` on reset.
await runGitCommand(directory, ['config', 'core.longpaths', 'true']);
};
export const populateWorktreeWithLockRecovery = async (directory) => {
let result = await runGitCommand(directory, ['reset', '--hard']);
await ensureWorktreeLongpaths(directory);
let result = await runGitCommand(directory, WORKTREE_POPULATE_RESET_ARGS);
if (result.success) {
return;
}
if (!isIndexLockError(result)) {
throw new Error(result.message || 'Failed to populate worktree');
throw new Error(formatWorktreePopulateError(result.message));
}
await wait(WORKTREE_INDEX_LOCK_RETRY_DELAY_MS);
result = await runGitCommand(directory, ['reset', '--hard']);
result = await runGitCommand(directory, WORKTREE_POPULATE_RESET_ARGS);
if (result.success) {
return;
}
if (!isIndexLockError(result)) {
throw new Error(result.message || 'Failed to populate worktree');
throw new Error(formatWorktreePopulateError(result.message));
}
const lockPath = await getWorktreeIndexLockPath(directory);
const identity = lockPath ? await getFileIdentity(lockPath) : null;
await wait(WORKTREE_INDEX_LOCK_STALE_DELAY_MS);
result = await runGitCommand(directory, ['reset', '--hard']);
result = await runGitCommand(directory, WORKTREE_POPULATE_RESET_ARGS);
if (result.success) {
return;
}
if (!isIndexLockError(result) || !lockPath || !identity || await getFileIdentity(lockPath) !== identity) {
throw new Error(result.message || 'Failed to populate worktree');
throw new Error(formatWorktreePopulateError(result.message));
}
await fsp.unlink(lockPath).catch((error) => {
@@ -1026,7 +1062,10 @@ export const populateWorktreeWithLockRecovery = async (directory) => {
throw error;
}
});
await runGitCommandOrThrow(directory, ['reset', '--hard'], 'Failed to populate worktree');
const finalResult = await runGitCommand(directory, WORKTREE_POPULATE_RESET_ARGS);
if (!finalResult.success) {
throw new Error(formatWorktreePopulateError(finalResult.message || 'Failed to populate worktree'));
}
};
// Worktrees are created with `git worktree add --no-checkout` and populated