GitNexus/src/services/git-clone.ts
2026-01-11 07:20:27 +05:30

225 lines
6 KiB
TypeScript

import git from 'isomorphic-git';
import http from 'isomorphic-git/http/web';
import LightningFS from '@isomorphic-git/lightning-fs';
import { shouldIgnorePath } from '../config/ignore-service';
import { FileEntry } from './zip';
// Initialize virtual filesystem (persists in IndexedDB)
// Use a unique name each time to avoid stale data issues
let fs: LightningFS;
let pfs: any;
const initFS = () => {
// Create a fresh filesystem instance
const fsName = `gitnexus-git-${Date.now()}`;
fs = new LightningFS(fsName);
pfs = fs.promises;
return fsName;
};
// Use public proxy in development, a custom proxy in production
const USE_OWN_PROXY = !import.meta.env.DEV;
/**
* Custom HTTP client that uses a query-param based proxy in production
* isomorphic-git's default corsProxy appends URL as path, which doesn't work
* well with Vercel's file-based routing.
*/
const createProxiedHttp = (): typeof http => {
if (!USE_OWN_PROXY) {
// In dev, use the public proxy via isomorphic-git's built-in corsProxy option
return http;
}
// In production, wrap the HTTP client to use the custom proxy
return {
request: async (config) => {
// Rewrite the URL to go through the proxy
const proxyUrl = `/api/proxy?url=${encodeURIComponent(config.url)}`;
// Call the original http.request with the proxied URL
return http.request({
...config,
url: proxyUrl,
});
},
};
};
/**
* Parse GitHub URL to extract owner and repo
* Supports:
* - https://github.com/owner/repo
* - https://github.com/owner/repo.git
* - github.com/owner/repo
*/
export const parseGitHubUrl = (url: string): { owner: string; repo: string } | null => {
const cleaned = url.trim().replace(/\.git$/, '');
const match = cleaned.match(/github\.com\/([^\/]+)\/([^\/]+)/);
if (!match) return null;
return {
owner: match[1],
repo: match[2],
};
};
/**
* Clone a GitHub repository using isomorphic-git
* Returns files in the same format as extractZip for compatibility
*
* @param url - GitHub repository URL
* @param onProgress - Progress callback
* @param token - Optional GitHub PAT for private repos (stays client-side only)
*/
export const cloneRepository = async (
url: string,
onProgress?: (phase: string, progress: number) => void,
token?: string
): Promise<FileEntry[]> => {
const parsed = parseGitHubUrl(url);
if (!parsed) {
throw new Error('Invalid GitHub URL. Use format: https://github.com/owner/repo');
}
// Initialize fresh filesystem to avoid stale IndexedDB data
const fsName = initFS();
const dir = `/${parsed.repo}`;
const repoUrl = `https://github.com/${parsed.owner}/${parsed.repo}.git`;
try {
onProgress?.('cloning', 0);
const httpClient = createProxiedHttp();
// Clone with shallow depth for speed
await git.clone({
fs,
http: httpClient,
dir,
url: repoUrl,
// Only use corsProxy in dev mode (with public proxy)
...(import.meta.env.DEV ? { corsProxy: 'https://cors.isomorphic-git.org' } : {}),
singleBranch: true,
depth: 1,
// Auth callback for private repos (PAT stays client-side)
onAuth: token ? () => ({ username: token, password: 'x-oauth-basic' }) : undefined,
onProgress: (event) => {
if (event.total) {
const percent = Math.round((event.loaded / event.total) * 100);
onProgress?.('cloning', percent);
}
},
});
onProgress?.('reading', 0);
// Read all files from the cloned repo
const files = await readAllFiles(dir, dir);
// Cleanup: remove the cloned repo from virtual FS to save space
await removeDirectory(dir);
// Also try to clean up the IndexedDB database
try {
indexedDB.deleteDatabase(fsName);
} catch {}
onProgress?.('complete', 100);
return files;
} catch (error) {
// Cleanup on error
try {
await removeDirectory(dir);
indexedDB.deleteDatabase(fsName);
} catch {}
throw error;
}
};
/**
* Recursively read all files from a directory in the virtual filesystem
*/
const readAllFiles = async (baseDir: string, currentDir: string): Promise<FileEntry[]> => {
const files: FileEntry[] = [];
let entries: string[];
try {
entries = await pfs.readdir(currentDir);
} catch (err) {
// Directory might not exist or be inaccessible
console.warn(`Cannot read directory: ${currentDir}`);
return files;
}
for (const entry of entries) {
// Skip .git directory
if (entry === '.git') continue;
const fullPath = `${currentDir}/${entry}`;
const relativePath = fullPath.replace(`${baseDir}/`, '');
// Check ignore rules
if (shouldIgnorePath(relativePath)) continue;
// Try to stat the file - skip if it fails (broken symlinks, etc.)
let stat;
try {
stat = await pfs.stat(fullPath);
} catch {
// Skip files that can't be stat'd (broken symlinks, permission issues)
if (import.meta.env.DEV) {
console.warn(`Skipping unreadable entry: ${relativePath}`);
}
continue;
}
if (stat.isDirectory()) {
// Recurse into subdirectory
const subFiles = await readAllFiles(baseDir, fullPath);
files.push(...subFiles);
} else {
// Read file content
try {
const content = await pfs.readFile(fullPath, { encoding: 'utf8' }) as string;
files.push({
path: relativePath,
content,
});
} catch {
// Skip binary files or files that can't be read as text
}
}
}
return files;
};
/**
* Recursively remove a directory from the virtual filesystem
*/
const removeDirectory = async (dir: string): Promise<void> => {
try {
const entries = await pfs.readdir(dir);
for (const entry of entries) {
const fullPath = `${dir}/${entry}`;
const stat = await pfs.stat(fullPath);
if (stat.isDirectory()) {
await removeDirectory(fullPath);
} else {
await pfs.unlink(fullPath);
}
}
await pfs.rmdir(dir);
} catch {
// Ignore errors during cleanup
}
};