Skip to content

Commit 956f388

Browse files
committed
feat: implement hybrid cache architecture for repository cloning
1 parent cc8fac5 commit 956f388

File tree

5 files changed

+453
-31
lines changed

5 files changed

+453
-31
lines changed
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import fs from 'fs';
2+
import path from 'path';
3+
import { getCacheConfig } from '../../../config';
4+
5+
export interface CacheStats {
6+
totalRepositories: number;
7+
totalSizeMB: number;
8+
repositories: Array<{
9+
name: string;
10+
sizeMB: number;
11+
lastAccessed: Date;
12+
}>;
13+
}
14+
15+
export class CacheManager {
16+
private cacheDir: string;
17+
private maxSizeGB: number;
18+
private maxRepositories: number;
19+
20+
constructor(
21+
cacheDir: string = './.remote/cache',
22+
maxSizeGB: number = 2,
23+
maxRepositories: number = 50,
24+
) {
25+
this.cacheDir = cacheDir;
26+
this.maxSizeGB = maxSizeGB;
27+
this.maxRepositories = maxRepositories;
28+
}
29+
30+
/**
31+
* Update access time for repository (for LRU purposes)
32+
*/
33+
touchRepository(repoName: string): void {
34+
const repoPath = path.join(this.cacheDir, repoName);
35+
if (fs.existsSync(repoPath)) {
36+
const now = new Date();
37+
fs.utimesSync(repoPath, now, now);
38+
}
39+
}
40+
41+
/**
42+
* Get cache statistics
43+
*/
44+
getCacheStats(): CacheStats {
45+
if (!fs.existsSync(this.cacheDir)) {
46+
return {
47+
totalRepositories: 0,
48+
totalSizeMB: 0,
49+
repositories: [],
50+
};
51+
}
52+
53+
const repositories: Array<{ name: string; sizeMB: number; lastAccessed: Date }> = [];
54+
let totalSizeMB = 0;
55+
56+
const entries = fs.readdirSync(this.cacheDir, { withFileTypes: true });
57+
58+
for (const entry of entries) {
59+
if (entry.isDirectory()) {
60+
const repoPath = path.join(this.cacheDir, entry.name);
61+
const sizeMB = this.getDirectorySize(repoPath);
62+
const stats = fs.statSync(repoPath);
63+
64+
repositories.push({
65+
name: entry.name,
66+
sizeMB,
67+
lastAccessed: stats.atime,
68+
});
69+
70+
totalSizeMB += sizeMB;
71+
}
72+
}
73+
74+
// Sort by last accessed (newest first)
75+
repositories.sort((a, b) => b.lastAccessed.getTime() - a.lastAccessed.getTime());
76+
77+
return {
78+
totalRepositories: repositories.length,
79+
totalSizeMB,
80+
repositories,
81+
};
82+
}
83+
84+
/**
85+
* Enforce cache limits using LRU eviction
86+
*/
87+
enforceLimits(): { removedRepos: string[]; freedMB: number } {
88+
const stats = this.getCacheStats();
89+
const removedRepos: string[] = [];
90+
let freedMB = 0;
91+
92+
// Sort repositories by last accessed (oldest first for removal)
93+
const reposToEvaluate = [...stats.repositories].sort(
94+
(a, b) => a.lastAccessed.getTime() - b.lastAccessed.getTime(),
95+
);
96+
97+
// Check size limit
98+
let currentSizeMB = stats.totalSizeMB;
99+
const maxSizeMB = this.maxSizeGB * 1024;
100+
101+
for (const repo of reposToEvaluate) {
102+
const shouldRemove =
103+
currentSizeMB > maxSizeMB || // Over size limit
104+
stats.totalRepositories - removedRepos.length > this.maxRepositories; // Over count limit
105+
106+
if (shouldRemove) {
107+
this.removeRepository(repo.name);
108+
removedRepos.push(repo.name);
109+
freedMB += repo.sizeMB;
110+
currentSizeMB -= repo.sizeMB;
111+
} else {
112+
break; // We've cleaned enough
113+
}
114+
}
115+
116+
return { removedRepos, freedMB };
117+
}
118+
119+
/**
120+
* Remove specific repository from cache
121+
*/
122+
private removeRepository(repoName: string): void {
123+
const repoPath = path.join(this.cacheDir, repoName);
124+
if (fs.existsSync(repoPath)) {
125+
fs.rmSync(repoPath, { recursive: true, force: true });
126+
}
127+
}
128+
129+
/**
130+
* Calculate directory size in MB
131+
*/
132+
private getDirectorySize(dirPath: string): number {
133+
let totalBytes = 0;
134+
135+
const calculateSize = (currentPath: string) => {
136+
const items = fs.readdirSync(currentPath, { withFileTypes: true });
137+
138+
for (const item of items) {
139+
const itemPath = path.join(currentPath, item.name);
140+
141+
if (item.isDirectory()) {
142+
calculateSize(itemPath);
143+
} else {
144+
try {
145+
const stats = fs.statSync(itemPath);
146+
totalBytes += stats.size;
147+
} catch (error) {
148+
// Skip files that can't be read
149+
}
150+
}
151+
}
152+
};
153+
154+
try {
155+
calculateSize(dirPath);
156+
} catch (error) {
157+
return 0;
158+
}
159+
160+
return Math.round(totalBytes / (1024 * 1024)); // Convert to MB
161+
}
162+
163+
/**
164+
* Get cache configuration
165+
*/
166+
getConfig() {
167+
return {
168+
maxSizeGB: this.maxSizeGB,
169+
maxRepositories: this.maxRepositories,
170+
cacheDir: this.cacheDir,
171+
};
172+
}
173+
}
174+
175+
// Global instance initialized with config
176+
const config = getCacheConfig();
177+
export const cacheManager = new CacheManager(
178+
config.cacheDir,
179+
config.maxSizeGB,
180+
config.maxRepositories,
181+
);

src/proxy/processors/push-action/clearBareClone.ts

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,41 @@
11
import { Action, Step } from '../../actions';
22
import fs from 'node:fs';
33

4+
const WORK_DIR = './.remote/work';
5+
46
const exec = async (req: any, action: Action): Promise<Action> => {
57
const step = new Step('clearBareClone');
68

7-
// Recursively remove the contents of ./.remote and ignore exceptions
8-
fs.rm('./.remote', { recursive: true, force: true }, (err) => {
9-
if (err) {
10-
throw err;
9+
// In test environment, clean up EVERYTHING to prevent memory leaks
10+
if (process.env.NODE_ENV === 'test') {
11+
// TEST: Full cleanup (bare cache + all working copies)
12+
try {
13+
if (fs.existsSync('./.remote')) {
14+
fs.rmSync('./.remote', { recursive: true, force: true });
15+
step.log('Test environment: Full .remote directory cleaned');
16+
} else {
17+
step.log('Test environment: .remote directory already clean');
18+
}
19+
} catch (err) {
20+
step.log(`Warning: Could not clean .remote directory: ${err}`);
21+
}
22+
} else {
23+
// PRODUCTION: Delete ONLY this push's working copy
24+
const workCopy = `${WORK_DIR}/${action.id}`;
25+
26+
if (fs.existsSync(workCopy)) {
27+
try {
28+
fs.rmSync(workCopy, { recursive: true, force: true });
29+
step.log(`Cleaned working copy for push ${action.id}`);
30+
} catch (err) {
31+
step.log(`Warning: Could not clean working copy ${workCopy}: ${err}`);
32+
}
33+
} else {
34+
step.log(`Working copy ${workCopy} not found (may have been already cleaned)`);
1135
}
12-
console.log(`.remote is deleted!`);
13-
});
36+
37+
step.log('Bare cache preserved for reuse');
38+
}
1439

1540
action.addStep(step);
1641
return action;
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { Step } from '../../actions';
2+
import { cacheManager } from './cache-manager';
3+
4+
/**
5+
* Git Operations for Hybrid Cache
6+
*/
7+
8+
/**
9+
* Execute a git command with credentials sanitization
10+
*/
11+
async function execGitCommand(
12+
command: string,
13+
step: Step,
14+
maxBuffer: number = 50 * 1024 * 1024,
15+
): Promise<{ stdout: string; stderr: string }> {
16+
const { exec } = await import('child_process');
17+
const { promisify } = await import('util');
18+
const execAsync = promisify(exec);
19+
20+
const { stdout, stderr } = await execAsync(command, { maxBuffer });
21+
22+
if (stdout) step.log(stdout.trim());
23+
if (stderr) step.log(stderr.trim());
24+
25+
return { stdout, stderr };
26+
}
27+
28+
/**
29+
* Build URL with embedded credentials
30+
*/
31+
function buildUrlWithCredentials(url: string, username: string, password: string): string {
32+
return url.replace('://', `://${encodeURIComponent(username)}:${encodeURIComponent(password)}@`);
33+
}
34+
35+
/**
36+
* Remove credentials from bare repository config
37+
*/
38+
async function sanitizeRepositoryConfig(bareRepo: string, cleanUrl: string): Promise<void> {
39+
const { exec } = await import('child_process');
40+
const { promisify } = await import('util');
41+
const execAsync = promisify(exec);
42+
43+
// Remove any URL with credentials
44+
await execAsync(`cd "${bareRepo}" && git config --unset remote.origin.url 2>/dev/null || true`);
45+
// Set clean URL without credentials
46+
await execAsync(`cd "${bareRepo}" && git config remote.origin.url "${cleanUrl}"`);
47+
}
48+
49+
/**
50+
* Clone working copy from bare repository using native git
51+
*/
52+
export async function cloneWorkingCopy(
53+
bareRepo: string,
54+
workCopyPath: string,
55+
step: Step,
56+
): Promise<void> {
57+
try {
58+
await execGitCommand(`git clone "${bareRepo}" "${workCopyPath}"`, step);
59+
step.log(`Working copy created at ${workCopyPath}`);
60+
} catch (error: any) {
61+
step.log(`Failed to create working copy: ${error.message}`);
62+
throw error;
63+
}
64+
}
65+
66+
/**
67+
* Fetch updates in bare repository using native git command
68+
*/
69+
export async function fetchBareRepository(
70+
bareRepo: string,
71+
url: string,
72+
username: string,
73+
password: string,
74+
step: Step,
75+
): Promise<void> {
76+
const urlWithCreds = buildUrlWithCredentials(url, username, password);
77+
78+
try {
79+
// Fetch all branches with depth=1
80+
await execGitCommand(
81+
`cd "${bareRepo}" && git fetch --depth=1 "${urlWithCreds}" "+refs/heads/*:refs/heads/*"`,
82+
step,
83+
);
84+
85+
// SECURITY: Remove credentials from config
86+
await sanitizeRepositoryConfig(bareRepo, url);
87+
88+
step.log(`Bare repository updated (credentials removed)`);
89+
} catch (error: any) {
90+
step.log(`Failed to fetch bare repository: ${error.message}`);
91+
throw error;
92+
}
93+
}
94+
95+
/**
96+
* Clone bare repository using native git command
97+
*/
98+
export async function cloneBareRepository(
99+
bareRepo: string,
100+
url: string,
101+
username: string,
102+
password: string,
103+
step: Step,
104+
): Promise<void> {
105+
const urlWithCreds = buildUrlWithCredentials(url, username, password);
106+
107+
try {
108+
await execGitCommand(`git clone --bare --depth=1 "${urlWithCreds}" "${bareRepo}"`, step);
109+
110+
// SECURITY: Remove credentials from config immediately after clone
111+
await sanitizeRepositoryConfig(bareRepo, url);
112+
113+
step.log(`Bare repository created at ${bareRepo} (credentials sanitized)`);
114+
115+
// Update access time for LRU after successful clone
116+
const repoName = bareRepo.split('/').pop() || '';
117+
cacheManager.touchRepository(repoName);
118+
} catch (error: any) {
119+
step.log(`Failed to clone bare repository: ${error.message}`);
120+
throw error;
121+
}
122+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { Step } from '../../actions';
2+
3+
/**
4+
* Performance Timer
5+
*
6+
* Logs basic timing info for operations
7+
*/
8+
export class PerformanceTimer {
9+
private step: Step;
10+
private startTime: number = 0;
11+
private operation: string = '';
12+
13+
constructor(step: Step) {
14+
this.step = step;
15+
}
16+
17+
start(operation: string): void {
18+
this.operation = operation;
19+
this.startTime = Date.now();
20+
this.step.log(`🚀 ${operation} started`);
21+
}
22+
23+
mark(message: string): void {
24+
if (this.startTime > 0) {
25+
const elapsed = Date.now() - this.startTime;
26+
this.step.log(`⚡ ${message}: ${elapsed}ms`);
27+
}
28+
}
29+
30+
end(): void {
31+
if (this.startTime > 0) {
32+
const totalTime = Date.now() - this.startTime;
33+
this.step.log(`✅ ${this.operation} completed: ${totalTime}ms`);
34+
this.startTime = 0;
35+
}
36+
}
37+
}

0 commit comments

Comments
 (0)