diff --git a/packages/vscode/src/DOCUMENTATION.md b/packages/vscode/src/DOCUMENTATION.md index 9218e072..d611986c 100644 --- a/packages/vscode/src/DOCUMENTATION.md +++ b/packages/vscode/src/DOCUMENTATION.md @@ -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. diff --git a/packages/vscode/src/gitService.ts b/packages/vscode/src/gitService.ts index 7f2a67d6..97b3a4d1 100644 --- a/packages/vscode/src/gitService.ts +++ b/packages/vscode/src/gitService.ts @@ -1117,34 +1117,71 @@ const getFileIdentity = async (filePath: string): Promise => { } }; +// OpenChamber places managed worktrees under a deep data-dir path +// (`/opencode/worktree/<40-char project id>//`). 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 => { + 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 => { - 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/opencode/worktree/<40-char root commit hash>/ +// 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'); + }); +}); diff --git a/packages/web/server/lib/git/service.js b/packages/web/server/lib/git/service.js index bd790acb..04baa715 100644 --- a/packages/web/server/lib/git/service.js +++ b/packages/web/server/lib/git/service.js @@ -991,34 +991,70 @@ const getFileIdentity = async (filePath) => { } }; +// OpenChamber places managed worktrees under a deep data-dir path +// (`/opencode/worktree/<40-char project id>//`). 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