diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index 4a710daf0..8589f5ae2 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -7,7 +7,7 @@ import * as path from 'node:path'; import * as os from 'node:os'; import * as fs from 'node:fs'; -import { getProjectHash } from '../utils/paths.js'; +import { getProjectHash, getLegacyProjectHash } from '../utils/paths.js'; export const QWEN_DIR = '.qwen'; export const GOOGLE_ACCOUNTS_FILENAME = 'google_accounts.json'; @@ -90,7 +90,25 @@ export class Storage { getProjectTempDir(): string { const hash = getProjectHash(this.getProjectRoot()); const tempDir = Storage.getGlobalTempDir(); - return path.join(tempDir, hash); + const targetDir = path.join(tempDir, hash); + + // Backward compatibility: On Windows, check if legacy directory exists + // and migrate it to the new normalized path + if (os.platform() === 'win32' && !fs.existsSync(targetDir)) { + const legacyHash = getLegacyProjectHash(this.getProjectRoot()); + const legacyDir = path.join(tempDir, legacyHash); + + if (fs.existsSync(legacyDir) && legacyHash !== hash) { + try { + // Attempt to rename/migrate the directory + fs.renameSync(legacyDir, targetDir); + } catch (_error) { + // Silent fallback: if migration fails, continue with the new path + } + } + } + + return targetDir; } ensureProjectTempDirExists(): void { @@ -108,7 +126,25 @@ export class Storage { getHistoryDir(): string { const hash = getProjectHash(this.getProjectRoot()); const historyDir = path.join(Storage.getGlobalQwenDir(), 'history'); - return path.join(historyDir, hash); + const targetDir = path.join(historyDir, hash); + + // Backward compatibility: On Windows, check if legacy directory exists + // and migrate it to the new normalized path + if (os.platform() === 'win32' && !fs.existsSync(targetDir)) { + const legacyHash = getLegacyProjectHash(this.getProjectRoot()); + const legacyDir = path.join(historyDir, legacyHash); + + if (fs.existsSync(legacyDir) && legacyHash !== hash) { + try { + // Attempt to rename/migrate the directory + fs.renameSync(legacyDir, targetDir); + } catch (_error) { + // Silent fallback: if migration fails, continue with the new path + } + } + } + + return targetDir; } getWorkspaceSettingsPath(): string { diff --git a/packages/core/src/core/logger.test.ts b/packages/core/src/core/logger.test.ts index de3fc3f78..c973c02dd 100644 --- a/packages/core/src/core/logger.test.ts +++ b/packages/core/src/core/logger.test.ts @@ -21,11 +21,11 @@ import { decodeTagName, } from './logger.js'; import { Storage } from '../config/storage.js'; +import { getProjectHash } from '../utils/paths.js'; import { promises as fs, existsSync } from 'node:fs'; import path from 'node:path'; import type { Content } from '@google/genai'; -import crypto from 'node:crypto'; import os from 'node:os'; const GEMINI_DIR_NAME = '.qwen'; @@ -34,7 +34,7 @@ const LOG_FILE_NAME = 'logs.json'; const CHECKPOINT_FILE_NAME = 'checkpoint.json'; const projectDir = process.cwd(); -const hash = crypto.createHash('sha256').update(projectDir).digest('hex'); +const hash = getProjectHash(projectDir); const TEST_HOME_DIR = path.join(os.tmpdir(), 'qwen-core-logger-home'); let originalHome: string | undefined; diff --git a/packages/core/src/utils/paths.test.ts b/packages/core/src/utils/paths.test.ts index 9f8b63ef9..2584e6503 100644 --- a/packages/core/src/utils/paths.test.ts +++ b/packages/core/src/utils/paths.test.ts @@ -18,6 +18,7 @@ import { shortenPath, tildeifyPath, getProjectHash, + getLegacyProjectHash, } from './paths.js'; import type { Config } from '../config/config.js'; @@ -848,3 +849,55 @@ describe('getProjectHash', () => { platformSpy.mockRestore(); }); }); + +describe('getLegacyProjectHash', () => { + it('should always be case-sensitive regardless of platform', () => { + const platformSpy = vi.spyOn(os, 'platform'); + + // Test on Windows - should still be case-sensitive + platformSpy.mockReturnValue('win32'); + const lowerCaseHash = getLegacyProjectHash('c:\\users\\test\\project'); + const upperCaseHash = getLegacyProjectHash('C:\\USERS\\TEST\\PROJECT'); + expect(lowerCaseHash).not.toBe(upperCaseHash); + + // Test on Linux - should be case-sensitive + platformSpy.mockReturnValue('linux'); + const lowerCaseHashLinux = getLegacyProjectHash('/home/user/project'); + const upperCaseHashLinux = getLegacyProjectHash('/HOME/USER/PROJECT'); + expect(lowerCaseHashLinux).not.toBe(upperCaseHashLinux); + + platformSpy.mockRestore(); + }); + + it('should generate different hash than getProjectHash on Windows with mixed case', () => { + const platformSpy = vi.spyOn(os, 'platform'); + platformSpy.mockReturnValue('win32'); + + const mixedCasePath = 'C:\\Users\\Test\\Project'; + const legacyHash = getLegacyProjectHash(mixedCasePath); + const newHash = getProjectHash(mixedCasePath); + + // They should be different because getProjectHash normalizes to lowercase + expect(legacyHash).not.toBe(newHash); + + // But both lowercase paths should match the new hash + const lowercaseNewHash = getProjectHash(mixedCasePath.toLowerCase()); + expect(newHash).toBe(lowercaseNewHash); + + platformSpy.mockRestore(); + }); + + it('should match getProjectHash on non-Windows platforms', () => { + const platformSpy = vi.spyOn(os, 'platform'); + platformSpy.mockReturnValue('linux'); + + const testPath = '/home/user/project'; + const legacyHash = getLegacyProjectHash(testPath); + const newHash = getProjectHash(testPath); + + // On non-Windows platforms, both should be identical + expect(legacyHash).toBe(newHash); + + platformSpy.mockRestore(); + }); +}); diff --git a/packages/core/src/utils/paths.ts b/packages/core/src/utils/paths.ts index 96856a5dc..f90e9b4e9 100644 --- a/packages/core/src/utils/paths.ts +++ b/packages/core/src/utils/paths.ts @@ -202,6 +202,17 @@ export function getProjectHash(projectRoot: string): string { return crypto.createHash('sha256').update(normalizedPath).digest('hex'); } +/** + * Generates a hash using the legacy algorithm (without case normalization). + * This is used for backward compatibility to locate session directories + * created before the case-insensitive fix on Windows. + * @param projectRoot The absolute path to the project's root directory. + * @returns A SHA256 hash of the project root path without normalization. + */ +export function getLegacyProjectHash(projectRoot: string): string { + return crypto.createHash('sha256').update(projectRoot).digest('hex'); +} + /** * Checks if a path is a subpath of another path. * @param parentPath The parent path. diff --git a/packages/vscode-ide-companion/src/services/qwenSessionManager.ts b/packages/vscode-ide-companion/src/services/qwenSessionManager.ts index 5c9f3d205..922ca78e5 100644 --- a/packages/vscode-ide-companion/src/services/qwenSessionManager.ts +++ b/packages/vscode-ide-companion/src/services/qwenSessionManager.ts @@ -8,6 +8,10 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import * as crypto from 'crypto'; +import { + getProjectHash, + getLegacyProjectHash, +} from '@qwen-code/qwen-code-core/src/utils/paths.js'; import type { QwenSession, QwenMessage } from './qwenSessionReader.js'; /** @@ -28,24 +32,36 @@ export class QwenSessionManager { } /** - * Calculate project hash (same as CLI) - * Qwen CLI uses SHA256 hash of the project path. - * On Windows, paths are case-insensitive, so we normalize to lowercase - * to ensure the same physical path always produces the same hash. - */ - private getProjectHash(workingDir: string): string { - // On Windows, normalize path to lowercase for case-insensitive matching - const normalizedPath = - os.platform() === 'win32' ? workingDir.toLowerCase() : workingDir; - return crypto.createHash('sha256').update(normalizedPath).digest('hex'); - } - - /** - * Get the session directory for a project + * Get the session directory for a project with backward compatibility */ private getSessionDir(workingDir: string): string { - const projectHash = this.getProjectHash(workingDir); - return path.join(this.qwenDir, 'tmp', projectHash, 'chats'); + const projectHash = getProjectHash(workingDir); + const sessionDir = path.join(this.qwenDir, 'tmp', projectHash, 'chats'); + + // Backward compatibility: On Windows, check if legacy directory exists + // and migrate it to the new normalized path + if (os.platform() === 'win32' && !fs.existsSync(sessionDir)) { + const legacyHash = getLegacyProjectHash(workingDir); + const legacySessionDir = path.join( + this.qwenDir, + 'tmp', + legacyHash, + 'chats', + ); + + if (fs.existsSync(legacySessionDir) && legacyHash !== projectHash) { + try { + // Migrate parent directory (hash directory, not just chats) + const newParentDir = path.join(this.qwenDir, 'tmp', projectHash); + const legacyParentDir = path.join(this.qwenDir, 'tmp', legacyHash); + fs.renameSync(legacyParentDir, newParentDir); + } catch (_error) { + // Silent fallback: if migration fails, continue with the new path + } + } + } + + return sessionDir; } /** @@ -92,7 +108,7 @@ export class QwenSessionManager { // Create session object const session: QwenSession = { sessionId, - projectHash: this.getProjectHash(workingDir), + projectHash: getProjectHash(workingDir), startTime: messages[0]?.timestamp || new Date().toISOString(), lastUpdated: new Date().toISOString(), messages, diff --git a/packages/vscode-ide-companion/src/services/qwenSessionReader.ts b/packages/vscode-ide-companion/src/services/qwenSessionReader.ts index 612cd2425..8215c8a20 100644 --- a/packages/vscode-ide-companion/src/services/qwenSessionReader.ts +++ b/packages/vscode-ide-companion/src/services/qwenSessionReader.ts @@ -8,7 +8,10 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import * as readline from 'readline'; -import * as crypto from 'crypto'; +import { + getProjectHash, + getLegacyProjectHash, +} from '@qwen-code/qwen-code-core/src/utils/paths.js'; export interface QwenMessage { id: string; @@ -58,8 +61,35 @@ export class QwenSessionReader { if (!allProjects && workingDir) { // Current project only - const projectHash = await this.getProjectHash(workingDir); + const projectHash = getProjectHash(workingDir); const chatsDir = path.join(this.qwenDir, 'tmp', projectHash, 'chats'); + + // Backward compatibility: On Windows, try legacy hash if new directory doesn't exist + if (os.platform() === 'win32' && !fs.existsSync(chatsDir)) { + const legacyHash = getLegacyProjectHash(workingDir); + const legacyChatsDir = path.join( + this.qwenDir, + 'tmp', + legacyHash, + 'chats', + ); + + if (fs.existsSync(legacyChatsDir) && legacyHash !== projectHash) { + try { + // Migrate parent directory + const newParentDir = path.join(this.qwenDir, 'tmp', projectHash); + const legacyParentDir = path.join( + this.qwenDir, + 'tmp', + legacyHash, + ); + fs.renameSync(legacyParentDir, newParentDir); + } catch (_error) { + // Silent fallback + } + } + } + const projectSessions = await this.readSessionsFromDir(chatsDir); sessions.push(...projectSessions); } else { @@ -177,19 +207,6 @@ export class QwenSessionReader { return found; } - /** - * Calculate project hash (needs to be consistent with Qwen CLI) - * Qwen CLI uses SHA256 hash of project path. - * On Windows, paths are case-insensitive, so we normalize to lowercase - * to ensure the same physical path always produces the same hash. - */ - private async getProjectHash(workingDir: string): Promise { - // On Windows, normalize path to lowercase for case-insensitive matching - const normalizedPath = - os.platform() === 'win32' ? workingDir.toLowerCase() : workingDir; - return crypto.createHash('sha256').update(normalizedPath).digest('hex'); - } - /** * Get session title (based on first user message) */ @@ -294,7 +311,7 @@ export class QwenSessionReader { } const projectHash = cwd - ? await this.getProjectHash(cwd) + ? getProjectHash(cwd) : path.basename(path.dirname(path.dirname(filePath))); return { diff --git a/scripts/telemetry_utils.js b/scripts/telemetry_utils.js index cb2010d5b..504ed18cb 100644 --- a/scripts/telemetry_utils.js +++ b/scripts/telemetry_utils.js @@ -18,10 +18,21 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const projectRoot = path.resolve(__dirname, '..'); -const projectHash = crypto - .createHash('sha256') - .update(projectRoot) - .digest('hex'); + +/** + * Generates a unique hash for a project based on its root path. + * On Windows, paths are case-insensitive, so we normalize to lowercase + * to ensure the same physical path always produces the same hash. + * This logic must match getProjectHash() in packages/core/src/utils/paths.ts + */ +function getProjectHash(projectRoot) { + // On Windows, normalize path to lowercase for case-insensitive matching + const normalizedPath = + os.platform() === 'win32' ? projectRoot.toLowerCase() : projectRoot; + return crypto.createHash('sha256').update(normalizedPath).digest('hex'); +} + +const projectHash = getProjectHash(projectRoot); // User-level .gemini directory in home const USER_GEMINI_DIR = path.join(os.homedir(), '.qwen');