Advertisement

← vibesdk

worker/agents/git/git-clone-service.ts

17.4 KB Download file
/**
 * Git clone service for building and serving repositories
 * Handles template rebasing and git HTTP protocol
 */

import git from '@ashishkumar472/cf-git';
import { MemFS } from './memfs';
import { createLogger } from '../../logger';
import type { TemplateDetails as SandboxTemplateDetails } from '../../services/sandbox/sandboxTypes';

const logger = createLogger('GitCloneService');

export interface RepositoryBuildOptions {
    gitObjects: Array<{ path: string; data: Uint8Array }>;
    templateDetails: SandboxTemplateDetails | null | undefined;
    appQuery: string;
    appCreatedAt?: Date;  // App creation timestamp for deterministic template commit
}

export class GitCloneService {
    /**
     * Build in-memory git repository by rebasing agent's git history on template files
     * 
     * Strategy:
     * 1. Create base commit with template files
     * 2. Import exported git objects from agent
     * 3. Update refs to point to agent's commits
     * 
     * Result: Template base + agent's commit history on top
     */
    static async buildRepository(options: RepositoryBuildOptions): Promise<MemFS> {
        const { gitObjects, templateDetails, appQuery, appCreatedAt } = options;
        const fs = new MemFS();
        
        try {
            logger.info('Building git repository with template rebase', { 
                templateName: templateDetails?.name,
                gitObjectCount: gitObjects.length
            });
            
            // If no agent commits yet, just create template base
            if (gitObjects.length === 0) {
                await git.init({ fs, dir: '/', defaultBranch: 'main' });
                
                if (templateDetails && templateDetails.allFiles) {
                    // Create template commit
                    for (const [path, content] of Object.entries(templateDetails.allFiles)) {
                        await fs.writeFile(path, content as string);
                        await git.add({ fs, dir: '/', filepath: path });
                    }
                    await git.commit({
                        fs, dir: '/',
                        message: `Template: ${templateDetails.name}`,
                        author: { 
                            name: 'Vibesdk', 
                            email: 'template@vibesdk.com',
                            timestamp: appCreatedAt ? Math.floor(appCreatedAt.getTime() / 1000) : 0
                        }
                    });
                }
                
                return fs;
            }
            
            await git.init({ fs, dir: '/', defaultBranch: 'main' });
            
            // Create template base commit
            let baseCommit: string | null = null;
            if (templateDetails && templateDetails.allFiles && Object.keys(templateDetails.allFiles).length > 0) {
                logger.info('Creating template base', { 
                    templateName: templateDetails.name,
                    fileCount: Object.keys(templateDetails.allFiles).length
                });
                
                for (const [path, content] of Object.entries(templateDetails.allFiles)) {
                    await fs.writeFile(path, content as string);
                    await git.add({ fs, dir: '/', filepath: path });
                }
                
                baseCommit = await git.commit({
                    fs, dir: '/',
                    message: `Template: ${templateDetails.name}\n\nBase template for ${appQuery}`,
                    author: { 
                        name: 'Vibesdk', 
                        email: 'template@vibesdk.com',
                        timestamp: appCreatedAt ? Math.floor(appCreatedAt.getTime() / 1000) : 0
                    }
                });
                
                logger.info('Template base created', { baseCommit });
            }
            
            // Find agent's HEAD from exported objects
            const headFile = gitObjects.find(obj => obj.path === '.git/HEAD');
            const headContent = headFile ? new TextDecoder().decode(headFile.data).trim() : null;
            
            let agentHeadOid: string | null = null;
            if (headContent && headContent.startsWith('ref: ')) {
                // HEAD is a ref, resolve it
                const refPath = headContent.slice(5).trim();
                const refFile = gitObjects.find(obj => obj.path === `.git/${refPath}`);
                agentHeadOid = refFile ? new TextDecoder().decode(refFile.data).trim() : null;
            } else if (headContent && headContent.length === 40) {
                // HEAD is direct SHA
                agentHeadOid = headContent;
            }
            
            if (!agentHeadOid) {
                throw new Error('Could not determine agent HEAD from exported objects');
            }
            
            logger.info('Found agent original HEAD', { agentHeadOid });
            
            // Import git objects (skip refs to preserve template base)
            logger.info('Importing agent git objects', { count: gitObjects.length });
            let importedCount = 0;
            for (const obj of gitObjects) {
                if (obj.path.startsWith('.git/refs/') || obj.path === '.git/HEAD' || obj.path === '.git/packed-refs') {
                    continue;
                }
                await fs.writeFile(obj.path, obj.data);
                importedCount++;
            }
            logger.info('Imported git objects (excluding refs)', { importedCount });
            
            // Get agent's commit history (oldest to newest)
            const agentLog = await git.log({ fs, dir: '/', ref: agentHeadOid });
            const commitsOldestFirst = agentLog.reverse();
            
            logger.info('Replaying agent commits on template base', { 
                commitCount: commitsOldestFirst.length,
                hasTemplate: !!baseCommit
            });
            
            // Replay agent commits on top of template base
            for (const commitInfo of commitsOldestFirst) {
                // Collect files from agent's commit tree
                const agentFiles: Array<{ path: string; oid: string }> = [];
                
                const walkTree = async (treeOid: string, basePath: string = '') => {
                    const { tree } = await git.readTree({ fs, dir: '/', oid: treeOid });
                    
                    for (const entry of tree) {
                        const fullPath = basePath ? `${basePath}/${entry.path}` : entry.path;
                        
                        if (entry.type === 'blob') {
                            agentFiles.push({ path: fullPath, oid: entry.oid });
                        } else if (entry.type === 'tree') {
                            await walkTree(entry.oid, fullPath);
                        }
                    }
                };
                
                await walkTree(commitInfo.commit.tree);
                
                // Write agent files
                for (const file of agentFiles) {
                    const { blob } = await git.readBlob({ fs, dir: '/', oid: file.oid });
                    await fs.writeFile(file.path, blob);
                }
                
                // Stage all files (template + agent)
                const allFiles: string[] = [];
                const getAllFiles = async (dir: string): Promise<void> => {
                    const entries = await fs.readdir(dir);
                    for (const entry of entries) {
                        const fullPath = dir === '/' ? entry : `${dir}/${entry}`;
                        if (entry === '.git' || fullPath === '.git') continue;
                        
                        const stat = await fs.stat(fullPath);
                        if (stat.type === 'dir') {
                            await getAllFiles(fullPath);
                        } else {
                            allFiles.push(fullPath.startsWith('/') ? fullPath.slice(1) : fullPath);
                        }
                    }
                };
                
                await getAllFiles('/');
                
                for (const filepath of allFiles) {
                    await git.add({ fs, dir: '/', filepath });
                }
                
                // Commit with original metadata
                await git.commit({
                    fs,
                    dir: '/',
                    message: commitInfo.commit.message,
                    author: commitInfo.commit.author,
                    committer: commitInfo.commit.committer || commitInfo.commit.author
                });
            }
            
            logger.info('Repository built with proper template base and agent commits');
            return fs;
        } catch (error) {
            logger.error('Failed to build git repository', { error });
            throw new Error(`Failed to build repository: ${error instanceof Error ? error.message : String(error)}`);
        }
    }


    /**
     * Build an in-memory repository by importing a `.git` object store verbatim.
     *
     * Unlike {@link buildRepository}, this performs no template rebase and no
     * commit replay: refs, HEAD, packed-refs, loose objects and packfiles are
     * written as-is, so the resulting repo is byte-for-byte the source history
     * (same commit oids). Used for think apps, whose real repository lives in
     * SpaceDO/Artifacts and must be served/pushed exactly.
     */
    static async buildRepositoryFromObjects(
        gitObjects: Array<{ path: string; data: Uint8Array }>,
    ): Promise<MemFS> {
        const fs = new MemFS();
        // Lay down a valid skeleton first; the real HEAD/refs/objects below
        // overwrite it (write-wins), so an incomplete export still yields a
        // usable repo.
        await git.init({ fs, dir: '/', defaultBranch: 'main' });
        for (const obj of gitObjects) {
            await fs.writeFile(obj.path, obj.data);
        }
        logger.info('Built repository from raw git objects', { objectCount: gitObjects.length });
        return fs;
    }

    /**
     * Handle git info/refs request
     * Returns advertisement of available refs for git clone
     */
    static async handleInfoRefs(fs: MemFS): Promise<string> {
        try {
            logger.info('Generating info/refs response');
            
            const head = await git.resolveRef({ fs, dir: '/', ref: 'HEAD' });
            logger.info('Resolved HEAD', { head });
            
            // Manually list branches from .git/refs/heads/ to avoid git.listBranches() hanging
            let branches: string[] = [];
            try {
                const headsDir = await fs.readdir('.git/refs/heads');
                branches = headsDir.filter((name: string) => !name.startsWith('.'));
                logger.info('Found branches', { branches });
            } catch (err) {
                logger.warn('No branches found', { error: err });
                branches = [];
            }
            
            // Git HTTP protocol: info/refs response format
            let response = '001e# service=git-upload-pack\n0000';
            
            // HEAD ref with capabilities
            const headLine = `${head} HEAD\0side-band-64k thin-pack ofs-delta agent=git/isomorphic-git\n`;
            response += this.formatPacketLine(headLine);
            
            // Branch refs
            for (const branch of branches) {
                try {
                    const oid = await git.resolveRef({ fs, dir: '/', ref: `refs/heads/${branch}` });
                    response += this.formatPacketLine(`${oid} refs/heads/${branch}\n`);
                    logger.info('Added branch ref', { branch, oid });
                } catch (err) {
                    logger.warn('Failed to resolve branch', { branch, error: err });
                }
            }
            
            // Flush packet
            response += '0000';
            
            logger.info('Generated info/refs response', { responseLength: response.length });
            return response;
        } catch (error) {
            logger.error('Failed to handle info/refs', { error });
            throw new Error(`Failed to get refs: ${error instanceof Error ? error.message : String(error)}`);
        }
    }

    /**
     * Handle git upload-pack request (actual clone operation)
     * Only includes reachable objects from HEAD for optimal pack size
     */
    static async handleUploadPack(fs: MemFS): Promise<Uint8Array> {
        try {
            // Collect only reachable objects from HEAD
            const reachableObjects = new Set<string>();
            
            // Get HEAD ref
            const head = await git.resolveRef({ fs, dir: '/', ref: 'HEAD' });
            reachableObjects.add(head);
            
            // Walk commit history
            const commits = await git.log({ fs, dir: '/', ref: 'HEAD' });
            
            for (const commit of commits) {
                // Add commit OID
                reachableObjects.add(commit.oid);
                
                // Add tree OID
                reachableObjects.add(commit.commit.tree);
                
                // Walk tree to get all blobs recursively
                try {
                    const collectTreeObjects = async (treeOid: string) => {
                        reachableObjects.add(treeOid);
                        const treeData = await git.readTree({ fs, dir: '/', oid: treeOid });
                        
                        for (const entry of treeData.tree) {
                            reachableObjects.add(entry.oid);
                            
                            // If it's a tree, recurse
                            if (entry.type === 'tree') {
                                await collectTreeObjects(entry.oid);
                            }
                        }
                    };
                    
                    await collectTreeObjects(commit.commit.tree);
                } catch (error) {
                    logger.warn('Failed to read tree for commit', { 
                        commitOid: commit.oid, 
                        error: error instanceof Error ? error.message : String(error)
                    });
                }
            }
            
            logger.info('Generating packfile with reachable objects only', { 
                objectCount: reachableObjects.size,
                commitCount: commits.length 
            });
            
            const packResult = await git.packObjects({ 
                fs, 
                dir: '/', 
                oids: Array.from(reachableObjects)
            });
            
            const packfile = packResult.packfile;
            
            if (!packfile) {
                throw new Error('Failed to generate packfile');
            }
            
            // NAK packet: "0008NAK\n"
            const nakPacket = new Uint8Array([
                0x30, 0x30, 0x30, 0x38,
                0x4e, 0x41, 0x4b,
                0x0a
            ]);
            
            // Wrap packfile in sideband format
            const sideband = this.wrapInSideband(packfile);
            
            // Concatenate NAK + sideband packfile
            const result = new Uint8Array(nakPacket.length + sideband.length);
            result.set(nakPacket, 0);
            result.set(sideband, nakPacket.length);
            
            return result;
        } catch (error) {
            logger.error('Failed to handle upload-pack', { error });
            throw new Error(`Failed to generate pack: ${error instanceof Error ? error.message : String(error)}`);
        }
    }

    /**
     * Format git packet line (4-byte hex length + data)
     */
    private static formatPacketLine(data: string): string {
        const length = data.length + 4;
        const hexLength = length.toString(16).padStart(4, '0');
        return hexLength + data;
    }

    /**
     * Wrap packfile in sideband-64k format
     */
    private static wrapInSideband(packfile: Uint8Array): Uint8Array {
        const CHUNK_SIZE = 65515;
        const chunks: Uint8Array[] = [];
        let offset = 0;
        while (offset < packfile.length) {
            const chunkSize = Math.min(CHUNK_SIZE, packfile.length - offset);
            const chunk = packfile.slice(offset, offset + chunkSize);
            
            const packetLength = 4 + 1 + chunkSize;
            const lengthHex = packetLength.toString(16).padStart(4, '0');
            const packet = new Uint8Array(4 + 1 + chunkSize);
            for (let i = 0; i < 4; i++) {
                packet[i] = lengthHex.charCodeAt(i);
            }
            packet[4] = 0x01;
            packet.set(chunk, 5);
            
            chunks.push(packet);
            offset += chunkSize;
        }
        
        const flush = new Uint8Array([0x30, 0x30, 0x30, 0x30]);
        chunks.push(flush);
        const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
        const result = new Uint8Array(totalLength);
        let resultOffset = 0;
        for (const chunk of chunks) {
            result.set(chunk, resultOffset);
            resultOffset += chunk.length;
        }
        
        return result;
    }
}