Add garbage collection for unused content-addressed askpass directories (#289723)

* Initial plan

* Add garbage collection for old content-addressed askpass directories

- Implement updateDirectoryMtime to update folder mtime when used
- Add garbageCollectOldDirectories to remove folders older than 7 days
- Update ensureAskpassScripts to call GC on every activation
- Add comprehensive test coverage for GC functionality

Co-authored-by: joaomoreno <22350+joaomoreno@users.noreply.github.com>

* Remove GC from fast path to keep it fast

Only run garbage collection when creating new directories, not when reusing existing ones. Old folders only accumulate when creating new content-addressed directories.

Co-authored-by: joaomoreno <22350+joaomoreno@users.noreply.github.com>

* Hoist askpassBaseDir variable to avoid duplication

Declare askpassBaseDir once at the top of the function and reuse it when constructing askpassDir and when calling garbageCollectOldDirectories.

Co-authored-by: joaomoreno <22350+joaomoreno@users.noreply.github.com>

* Fix test failures and address bot review comments

- Export ensureAskpassScripts for testing to avoid dependency on isWindowsUserOrSystemSetup check
- Remove redundant success log after directory removal
- Update tests to call ensureAskpassScripts directly instead of getAskpassPaths
- Remove Windows-only restrictions from tests to make them cross-platform
- Remove setTimeout workarounds - tests now properly await async operations

Co-authored-by: joaomoreno <22350+joaomoreno@users.noreply.github.com>

* formatting

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: joaomoreno <22350+joaomoreno@users.noreply.github.com>
Co-authored-by: João Moreno <joaomoreno@users.noreply.github.com>
This commit is contained in:
Copilot
2026-01-29 10:35:40 +00:00
committed by GitHub
parent d46d4d9de2
commit 1872cb3854
2 changed files with 284 additions and 2 deletions

View File

@@ -128,6 +128,74 @@ async function copyFileSecure(
await setWindowsPermissions(dest, logger);
}
/**
* Updates the modification time of a directory to mark it as recently used.
*/
async function updateDirectoryMtime(dirPath: string, logger: LogOutputChannel): Promise<void> {
try {
const now = new Date();
await fs.promises.utimes(dirPath, now, now);
logger.trace(`[askpassManager] Updated mtime for ${dirPath}`);
} catch (err) {
logger.warn(`[askpassManager] Failed to update mtime for ${dirPath}: ${err}`);
}
}
/**
* Garbage collects old content-addressed askpass directories that haven't been used in 7 days.
* This prevents accumulation of old versions when VS Code updates.
*/
async function garbageCollectOldDirectories(
askpassBaseDir: string,
currentHash: string,
logger: LogOutputChannel
): Promise<void> {
try {
// Check if the askpass base directory exists
try {
await fs.promises.access(askpassBaseDir);
} catch {
// Directory doesn't exist, nothing to clean
return;
}
const entries = await fs.promises.readdir(askpassBaseDir);
const sevenDaysAgo = Date.now() - (7 * 24 * 60 * 60 * 1000);
for (const entry of entries) {
// Skip the current content-addressed directory
if (entry === currentHash) {
continue;
}
const entryPath = path.join(askpassBaseDir, entry);
try {
const stat = await fs.promises.stat(entryPath);
// Only process directories
if (!stat.isDirectory()) {
continue;
}
// Check if the directory hasn't been used in 7 days
if (stat.mtime.getTime() < sevenDaysAgo) {
logger.info(`[askpassManager] Removing old askpass directory: ${entryPath} (last used: ${stat.mtime.toISOString()})`);
// Remove the directory and all its contents
await fs.promises.rm(entryPath, { recursive: true, force: true });
} else {
logger.trace(`[askpassManager] Keeping askpass directory: ${entryPath} (last used: ${stat.mtime.toISOString()})`);
}
} catch (err) {
logger.warn(`[askpassManager] Failed to process/remove directory ${entryPath}: ${err}`);
}
}
} catch (err) {
logger.warn(`[askpassManager] Failed to garbage collect old directories: ${err}`);
}
}
export interface AskpassPaths {
readonly askpass: string;
readonly askpassMain: string;
@@ -144,7 +212,7 @@ export interface AskpassPaths {
* @param storageDir The user-controlled storage directory (context.storageUri.fsPath)
* @param logger Logger for diagnostic output
*/
async function ensureAskpassScripts(
export async function ensureAskpassScripts(
sourceDir: string,
storageDir: string,
logger: LogOutputChannel
@@ -162,7 +230,8 @@ async function ensureAskpassScripts(
logger.trace(`[askpassManager] Content hash: ${contentHash}`);
// Create content-addressed directory
const askpassDir = path.join(storageDir, 'askpass', contentHash);
const askpassBaseDir = path.join(storageDir, 'askpass');
const askpassDir = path.join(askpassBaseDir, contentHash);
const destPaths: AskpassPaths = {
askpass: path.join(askpassDir, 'askpass.sh'),
@@ -177,6 +246,10 @@ async function ensureAskpassScripts(
const stat = await fs.promises.stat(destPaths.askpass);
if (stat.isFile()) {
logger.trace(`[askpassManager] Using existing content-addressed askpass at ${askpassDir}`);
// Update mtime to mark this directory as recently used
await updateDirectoryMtime(askpassDir, logger);
return destPaths;
}
} catch {
@@ -200,6 +273,12 @@ async function ensureAskpassScripts(
logger.info(`[askpassManager] Successfully created content-addressed askpass scripts`);
// Update mtime to mark this directory as recently used
await updateDirectoryMtime(askpassDir, logger);
// Garbage collect old directories
await garbageCollectOldDirectories(askpassBaseDir, contentHash, logger);
return destPaths;
}