Skip to content

Commit 6745b27

Browse files
authored
Merge pull request #394 from rajbos/repo-grouping
Enhance workspace path handling by deduplicating paths with case differences and remote paths
2 parents 18f6ee2 + 1ddde63 commit 6745b27

File tree

3 files changed

+102
-2
lines changed

3 files changed

+102
-2
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "copilot-token-tracker",
33
"displayName": "Copilot Token Tracker",
44
"description": "Shows daily and monthly (estimated) GitHub Copilot token usage stats in VS Code status bar",
5-
"version": "0.0.16",
5+
"version": "0.0.17",
66
"publisher": "RobBos",
77
"engines": {
88
"vscode": "^1.109.0"

src/extension.ts

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1494,6 +1494,95 @@ class CopilotTokenTracker implements vscode.Disposable {
14941494
}
14951495
}
14961496

1497+
// Deduplicate workspace paths that resolve to the same physical repository.
1498+
// Two sources of duplication are handled:
1499+
//
1500+
// 1. Case differences on case-insensitive filesystems (Windows/macOS):
1501+
// Different VS Code variants may store the same folder as "C:\Users\..." vs "c:\users\...".
1502+
// Detected by lowercasing the full path.
1503+
//
1504+
// 2. Remote/devcontainer paths for the same local repo:
1505+
// Opening a devcontainer for a local project stores a vscode-remote:// URI whose
1506+
// resolved fsPath is the *container-internal* path (e.g. "/workspaces/my-repo"),
1507+
// while normal sessions store the local Windows path.
1508+
// Both have the same basename, and one of them is a non-local path
1509+
// (starts with "/workspaces/" or is a Unix-style absolute path on Windows).
1510+
// Detected by matching basename case-insensitively when one entry is a remote path.
1511+
//
1512+
// In both cases: session/interaction counts are summed; customization file scan results
1513+
// are kept from whichever path has more files (the local path wins for scanning).
1514+
{
1515+
const mergeInto = (winner: string, loser: string) => {
1516+
workspaceSessionCounts.set(winner,
1517+
(workspaceSessionCounts.get(winner) || 0) + (workspaceSessionCounts.get(loser) || 0));
1518+
workspaceInteractionCounts.set(winner,
1519+
(workspaceInteractionCounts.get(winner) || 0) + (workspaceInteractionCounts.get(loser) || 0));
1520+
workspaceSessionCounts.delete(loser);
1521+
workspaceInteractionCounts.delete(loser);
1522+
const winnerFiles = this._customizationFilesCache.get(winner) || [];
1523+
const loserFiles = this._customizationFilesCache.get(loser) || [];
1524+
if (winnerFiles.length === 0 && loserFiles.length > 0) {
1525+
this._customizationFilesCache.set(winner, loserFiles);
1526+
}
1527+
this._customizationFilesCache.delete(loser);
1528+
};
1529+
1530+
// Helper: true when path looks like a remote/devcontainer path on Windows
1531+
// (Unix-style absolute path, e.g. "/workspaces/repo" or "/home/user/repo")
1532+
const isRemotePath = (p: string) => {
1533+
if (process.platform !== 'win32') { return false; }
1534+
const normalized = p.replace(/\\/g, '/');
1535+
return normalized.startsWith('/');
1536+
};
1537+
1538+
// Pass 1 — case-insensitive dedup (covers casing differences between editor variants)
1539+
if (process.platform === 'win32' || process.platform === 'darwin') {
1540+
const lowerToCanonical = new Map<string, string>();
1541+
for (const key of Array.from(workspaceSessionCounts.keys())) {
1542+
const lower = key.toLowerCase();
1543+
if (!lowerToCanonical.has(lower)) {
1544+
lowerToCanonical.set(lower, key);
1545+
} else {
1546+
const canonical = lowerToCanonical.get(lower)!;
1547+
// Prefer the local (non-remote) path as winner; otherwise more sessions wins
1548+
const canonicalIsRemote = isRemotePath(canonical);
1549+
const keyIsRemote = isRemotePath(key);
1550+
const winner = (!keyIsRemote && canonicalIsRemote)
1551+
? key
1552+
: (!canonicalIsRemote && keyIsRemote)
1553+
? canonical
1554+
: (workspaceSessionCounts.get(key) || 0) >= (workspaceSessionCounts.get(canonical) || 0)
1555+
? key : canonical;
1556+
const loser = winner === key ? canonical : key;
1557+
mergeInto(winner, loser);
1558+
lowerToCanonical.set(lower, winner);
1559+
}
1560+
}
1561+
}
1562+
1563+
// Pass 2 — basename dedup for remote/devcontainer paths.
1564+
// When one path is a remote (Unix-style) path and another is a local path with the
1565+
// same basename, they represent the same physical repo opened via a devcontainer.
1566+
if (process.platform === 'win32') {
1567+
const basenameToLocal = new Map<string, string>(); // lower-basename → local path key
1568+
for (const key of Array.from(workspaceSessionCounts.keys())) {
1569+
if (!isRemotePath(key)) {
1570+
basenameToLocal.set(path.basename(key).toLowerCase(), key);
1571+
}
1572+
}
1573+
for (const key of Array.from(workspaceSessionCounts.keys())) {
1574+
if (isRemotePath(key)) {
1575+
const base = path.basename(key).toLowerCase();
1576+
const localKey = basenameToLocal.get(base);
1577+
if (localKey && workspaceSessionCounts.has(key)) {
1578+
// Merge remote into local — local wins because we can scan its files
1579+
mergeInto(localKey, key);
1580+
}
1581+
}
1582+
}
1583+
}
1584+
}
1585+
14971586
// Build the customization matrix using scanned workspace data and session counts
14981587
try {
14991588
// Unique customization types based on Copilot patterns only
@@ -6570,7 +6659,7 @@ export function activate(context: vscode.ExtensionContext) {
65706659
waterUsagePer1kTokens: 0.3,
65716660
co2AbsorptionPerTreePerYear: 21000,
65726661
getCopilotSessionFiles: () =>
6573-
(tokenTracker as any).getCopilotSessionFiles(),
6662+
(tokenTracker as any).sessionDiscovery.getCopilotSessionFiles(),
65746663
estimateTokensFromText: (text: string, model?: string) =>
65756664
(tokenTracker as any).estimateTokensFromText(text, model),
65766665
getModelFromRequest: (req: any) =>

src/workspaceHelpers.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -636,6 +636,17 @@ export function resolveWorkspaceFolderFromSessionPath(sessionFilePath: string, w
636636
return undefined;
637637
}
638638

639+
// Canonicalize path casing using the real filesystem path.
640+
// Different VS Code variants (Stable, Insiders, Cursor) may store the same folder with
641+
// different drive-letter or path casing in their workspace.json (e.g. "C:\Users\" vs "c:\users\").
642+
// realpathSync.native returns the true OS-level casing, so the same physical folder always
643+
// produces the same Map key and is deduplicated correctly.
644+
try {
645+
folderFsPath = fs.realpathSync.native(folderFsPath);
646+
} catch {
647+
// Path may not exist on disk (deleted/moved repo); keep the parsed path as-is.
648+
}
649+
639650
workspaceIdToFolderCache.set(workspaceId, folderFsPath);
640651
return folderFsPath;
641652
} catch (err) {

0 commit comments

Comments
 (0)