Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions packages/core/src/config/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@

import * as path from 'node:path';
import * as os from 'node:os';
import * as crypto from 'node:crypto';
import * as fs from 'node:fs';
import { getProjectHash } from '../utils/paths.js';

export const QWEN_DIR = '.qwen';
export const GOOGLE_ACCOUNTS_FILENAME = 'google_accounts.json';
Expand Down Expand Up @@ -88,9 +88,10 @@ export class Storage {
}

getProjectTempDir(): string {
const hash = this.getFilePathHash(this.getProjectRoot());
const hash = getProjectHash(this.getProjectRoot());
const tempDir = Storage.getGlobalTempDir();
return path.join(tempDir, hash);
const targetDir = path.join(tempDir, hash);
return targetDir;
}

ensureProjectTempDirExists(): void {
Expand All @@ -105,14 +106,11 @@ export class Storage {
return this.targetDir;
}

private getFilePathHash(filePath: string): string {
return crypto.createHash('sha256').update(filePath).digest('hex');
}

getHistoryDir(): string {
const hash = this.getFilePathHash(this.getProjectRoot());
const hash = getProjectHash(this.getProjectRoot());
const historyDir = path.join(Storage.getGlobalQwenDir(), 'history');
return path.join(historyDir, hash);
const targetDir = path.join(historyDir, hash);
return targetDir;
}

getWorkspaceSettingsPath(): string {
Expand Down Expand Up @@ -144,6 +142,8 @@ export class Storage {
}

private sanitizeCwd(cwd: string): string {
return cwd.replace(/[^a-zA-Z0-9]/g, '-');
// On Windows, normalize to lowercase for case-insensitive matching
const normalizedCwd = os.platform() === 'win32' ? cwd.toLowerCase() : cwd;
return normalizedCwd.replace(/[^a-zA-Z0-9]/g, '-');
}
}
4 changes: 2 additions & 2 deletions packages/core/src/core/logger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand Down
78 changes: 78 additions & 0 deletions packages/core/src/utils/paths.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
isSubpath,
shortenPath,
tildeifyPath,
getProjectHash,
} from './paths.js';
import type { Config } from '../config/config.js';

Expand Down Expand Up @@ -770,3 +771,80 @@ describe('shortenPath', () => {
expect(result.length).toBeLessThanOrEqual(35);
});
});

describe('getProjectHash', () => {
it('should generate consistent hashes for the same path', () => {
const projectRoot = '/test/project';
const hash1 = getProjectHash(projectRoot);
const hash2 = getProjectHash(projectRoot);

expect(hash1).toBe(hash2);
expect(hash1).toHaveLength(64); // SHA256 produces 64 hex characters
});

it('should generate different hashes for different paths', () => {
const hash1 = getProjectHash('/test/project1');
const hash2 = getProjectHash('/test/project2');

expect(hash1).not.toBe(hash2);
});

it('should generate case-insensitive hashes on Windows', () => {
const platformSpy = vi.spyOn(os, 'platform');

// Simulate Windows platform
platformSpy.mockReturnValue('win32');

const lowerCasePath = 'c:\\users\\test\\project';
const upperCasePath = 'C:\\Users\\Test\\Project';
const mixedCasePath = 'c:\\Users\\TEST\\project';

const hash1 = getProjectHash(lowerCasePath);
const hash2 = getProjectHash(upperCasePath);
const hash3 = getProjectHash(mixedCasePath);

// On Windows, all different case variations should produce the same hash
expect(hash1).toBe(hash2);
expect(hash2).toBe(hash3);

platformSpy.mockRestore();
});

it('should generate case-sensitive hashes on non-Windows platforms', () => {
const platformSpy = vi.spyOn(os, 'platform');

// Simulate Unix/Linux platform
platformSpy.mockReturnValue('linux');

const lowerCasePath = '/home/user/project';
const upperCasePath = '/HOME/USER/PROJECT';

const hash1 = getProjectHash(lowerCasePath);
const hash2 = getProjectHash(upperCasePath);

// On non-Windows platforms, different case should produce different hashes
expect(hash1).not.toBe(hash2);

platformSpy.mockRestore();
});

it('should handle Windows drive letter variations', () => {
const platformSpy = vi.spyOn(os, 'platform');
platformSpy.mockReturnValue('win32');

// Common Windows scenarios where users might have different drive letter cases
const scenarios = [
['e:\\work', 'E:\\work'],
['e:\\work', 'E:\\WORK'],
['c:\\projects\\myapp', 'C:\\Projects\\MyApp'],
];

for (const [path1, path2] of scenarios) {
const hash1 = getProjectHash(path1);
const hash2 = getProjectHash(path2);
expect(hash1).toBe(hash2);
}

platformSpy.mockRestore();
});
});
7 changes: 6 additions & 1 deletion packages/core/src/utils/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,11 +190,16 @@ export function unescapePath(filePath: string): string {

/**
* 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.
* @param projectRoot The absolute path to the project's root directory.
* @returns A SHA256 hash of the project root path.
*/
export function getProjectHash(projectRoot: string): string {
return crypto.createHash('sha256').update(projectRoot).digest('hex');
// 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');
}

/**
Expand Down
18 changes: 6 additions & 12 deletions packages/vscode-ide-companion/src/services/qwenSessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import * as crypto from 'crypto';
import { getProjectHash } from '@qwen-code/qwen-code-core/src/utils/paths.js';
import type { QwenSession, QwenMessage } from './qwenSessionReader.js';

/**
Expand All @@ -28,19 +29,12 @@ export class QwenSessionManager {
}

/**
* Calculate project hash (same as CLI)
* Qwen CLI uses SHA256 hash of the project path
*/
private getProjectHash(workingDir: string): string {
return crypto.createHash('sha256').update(workingDir).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');
return sessionDir;
}

/**
Expand Down Expand Up @@ -87,7 +81,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,
Expand Down
14 changes: 3 additions & 11 deletions packages/vscode-ide-companion/src/services/qwenSessionReader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ 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 } from '@qwen-code/qwen-code-core/src/utils/paths.js';

export interface QwenMessage {
id: string;
Expand Down Expand Up @@ -58,7 +58,7 @@ 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');
const projectSessions = await this.readSessionsFromDir(chatsDir);
sessions.push(...projectSessions);
Expand Down Expand Up @@ -177,14 +177,6 @@ export class QwenSessionReader {
return found;
}

/**
* Calculate project hash (needs to be consistent with Qwen CLI)
* Qwen CLI uses SHA256 hash of project path
*/
private async getProjectHash(workingDir: string): Promise<string> {
return crypto.createHash('sha256').update(workingDir).digest('hex');
}

/**
* Get session title (based on first user message)
*/
Expand Down Expand Up @@ -289,7 +281,7 @@ export class QwenSessionReader {
}

const projectHash = cwd
? await this.getProjectHash(cwd)
? getProjectHash(cwd)
: path.basename(path.dirname(path.dirname(filePath)));

return {
Expand Down
19 changes: 15 additions & 4 deletions scripts/telemetry_utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down