|
| 1 | +/** |
| 2 | + * Git clone service for building and serving repositories |
| 3 | + * Handles template rebasing and git HTTP protocol |
| 4 | + */ |
| 5 | + |
| 6 | +import git from 'isomorphic-git'; |
| 7 | +import { MemFS } from './memfs'; |
| 8 | +import { SqliteFS } from './fs-adapter'; |
| 9 | +import { createLogger } from '../../logger'; |
| 10 | +import type { TemplateDetails as SandboxTemplateDetails } from '../../services/sandbox/sandboxTypes'; |
| 11 | + |
| 12 | +const logger = createLogger('GitCloneService'); |
| 13 | + |
| 14 | +export interface RepositoryBuildOptions { |
| 15 | + agentGitFS: SqliteFS; |
| 16 | + templateDetails: SandboxTemplateDetails | null | undefined; |
| 17 | + appQuery: string; |
| 18 | +} |
| 19 | + |
| 20 | +export class GitCloneService { |
| 21 | + /** |
| 22 | + * Build in-memory git repository by rebasing agent's git history on template files |
| 23 | + * |
| 24 | + * Strategy: |
| 25 | + * 1. Create base commit with template files |
| 26 | + * 2. Export all git objects from agent's repo (SqliteFS) |
| 27 | + * 3. Import git objects into MemFS |
| 28 | + * 4. Update refs to point to agent's commits |
| 29 | + * |
| 30 | + * Result: Template base + agent's commit history on top |
| 31 | + */ |
| 32 | + static async buildRepository(options: RepositoryBuildOptions): Promise<MemFS> { |
| 33 | + const { agentGitFS, templateDetails, appQuery } = options; |
| 34 | + const fs = new MemFS(); |
| 35 | + |
| 36 | + try { |
| 37 | + logger.info('Building git repository with template rebasing', { |
| 38 | + templateName: templateDetails?.name, |
| 39 | + templateFileCount: templateDetails ? Object.keys(templateDetails.allFiles).length : 0 |
| 40 | + }); |
| 41 | + |
| 42 | + // Step 1: Create base commit with template files |
| 43 | + await git.init({ fs, dir: '/', defaultBranch: 'main' }); |
| 44 | + |
| 45 | + if (templateDetails?.allFiles) { |
| 46 | + // Write template files |
| 47 | + for (const [path, content] of Object.entries(templateDetails.allFiles)) { |
| 48 | + fs.writeFile(path, content); |
| 49 | + } |
| 50 | + |
| 51 | + // Stage and commit template files |
| 52 | + await git.add({ fs, dir: '/', filepath: '.' }); |
| 53 | + const templateCommitOid = await git.commit({ |
| 54 | + fs, |
| 55 | + dir: '/', |
| 56 | + message: `Template: ${templateDetails.name}\n\nBase template for Vibesdk application\nQuery: ${appQuery}`, |
| 57 | + author: { |
| 58 | + name: 'Vibesdk Template', |
| 59 | + email: 'templates@cloudflare.dev', |
| 60 | + timestamp: Math.floor(Date.now() / 1000) |
| 61 | + } |
| 62 | + }); |
| 63 | + |
| 64 | + logger.info('Created template base commit', { oid: templateCommitOid }); |
| 65 | + } |
| 66 | + |
| 67 | + // Step 2: Check if agent has git history |
| 68 | + const agentHasGitRepo = await this.hasGitRepository(agentGitFS); |
| 69 | + |
| 70 | + if (agentHasGitRepo) { |
| 71 | + // Step 3: Export all git objects from agent's repo and import to MemFS |
| 72 | + await this.copyGitObjects(agentGitFS, fs); |
| 73 | + |
| 74 | + // Step 4: Get agent's HEAD |
| 75 | + try { |
| 76 | + const agentHeadOid = await git.resolveRef({ fs: agentGitFS, dir: '/', ref: 'HEAD' }); |
| 77 | + |
| 78 | + // Update main branch to point to agent's HEAD |
| 79 | + // This effectively rebases agent's commits on top of template |
| 80 | + await git.writeRef({ |
| 81 | + fs, |
| 82 | + dir: '/', |
| 83 | + ref: 'refs/heads/main', |
| 84 | + value: agentHeadOid, |
| 85 | + force: true |
| 86 | + }); |
| 87 | + |
| 88 | + logger.info('Rebased agent history on template', { |
| 89 | + agentHead: agentHeadOid |
| 90 | + }); |
| 91 | + } catch (error) { |
| 92 | + logger.warn('Could not rebase agent history', { error }); |
| 93 | + // Template commit is already in place, continue |
| 94 | + } |
| 95 | + } else { |
| 96 | + logger.info('No agent git history found, using template only'); |
| 97 | + } |
| 98 | + |
| 99 | + logger.info('Git repository built successfully'); |
| 100 | + return fs; |
| 101 | + } catch (error) { |
| 102 | + logger.error('Failed to build git repository', { error }); |
| 103 | + throw new Error(`Failed to build repository: ${error instanceof Error ? error.message : String(error)}`); |
| 104 | + } |
| 105 | + } |
| 106 | + |
| 107 | + /** |
| 108 | + * Check if agent has initialized git repository |
| 109 | + */ |
| 110 | + private static async hasGitRepository(agentFS: SqliteFS): Promise<boolean> { |
| 111 | + try { |
| 112 | + agentFS.readdir('/.git'); |
| 113 | + return true; |
| 114 | + } catch { |
| 115 | + return false; |
| 116 | + } |
| 117 | + } |
| 118 | + |
| 119 | + /** |
| 120 | + * Copy all git objects from agent's SqliteFS to MemFS |
| 121 | + * This includes commits, trees, and blobs |
| 122 | + */ |
| 123 | + private static async copyGitObjects(sourceFS: SqliteFS, targetFS: MemFS): Promise<void> { |
| 124 | + try { |
| 125 | + // Copy the entire .git directory structure |
| 126 | + await this.copyDirectory(sourceFS, targetFS, '/.git'); |
| 127 | + logger.info('Copied git objects from agent to MemFS'); |
| 128 | + } catch (error) { |
| 129 | + logger.error('Failed to copy git objects', { error }); |
| 130 | + throw error; |
| 131 | + } |
| 132 | + } |
| 133 | + |
| 134 | + /** |
| 135 | + * Recursively copy directory from source to target filesystem |
| 136 | + */ |
| 137 | + private static async copyDirectory( |
| 138 | + sourceFS: SqliteFS, |
| 139 | + targetFS: MemFS, |
| 140 | + dirPath: string |
| 141 | + ): Promise<void> { |
| 142 | + const entries = sourceFS.readdir(dirPath); |
| 143 | + |
| 144 | + for (const entry of entries) { |
| 145 | + const fullPath = `${dirPath}/${entry}`; |
| 146 | + |
| 147 | + try { |
| 148 | + const stat = sourceFS.stat(fullPath); |
| 149 | + |
| 150 | + if (stat.type === 'file') { |
| 151 | + // Copy file |
| 152 | + const data = sourceFS.readFile(fullPath); |
| 153 | + targetFS.writeFile(fullPath, data as Uint8Array); |
| 154 | + } else if (stat.type === 'dir') { |
| 155 | + // Recursively copy directory |
| 156 | + await this.copyDirectory(sourceFS, targetFS, fullPath); |
| 157 | + } |
| 158 | + } catch (error) { |
| 159 | + logger.warn(`Failed to copy ${fullPath}`, { error }); |
| 160 | + // Continue with other files |
| 161 | + } |
| 162 | + } |
| 163 | + } |
| 164 | + |
| 165 | + /** |
| 166 | + * Handle git info/refs request |
| 167 | + * Returns advertisement of available refs for git clone |
| 168 | + */ |
| 169 | + static async handleInfoRefs(fs: MemFS): Promise<string> { |
| 170 | + try { |
| 171 | + const head = await git.resolveRef({ fs, dir: '/', ref: 'HEAD' }); |
| 172 | + const branches = await git.listBranches({ fs, dir: '/' }); |
| 173 | + |
| 174 | + // Git HTTP protocol: info/refs response format |
| 175 | + let response = '001e# service=git-upload-pack\n0000'; |
| 176 | + |
| 177 | + // HEAD ref with capabilities |
| 178 | + const headLine = `${head} HEAD\0side-band-64k thin-pack ofs-delta agent=git/isomorphic-git\n`; |
| 179 | + response += this.formatPacketLine(headLine); |
| 180 | + |
| 181 | + // Branch refs |
| 182 | + for (const branch of branches) { |
| 183 | + const oid = await git.resolveRef({ fs, dir: '/', ref: `refs/heads/${branch}` }); |
| 184 | + response += this.formatPacketLine(`${oid} refs/heads/${branch}\n`); |
| 185 | + } |
| 186 | + |
| 187 | + // Flush packet |
| 188 | + response += '0000'; |
| 189 | + |
| 190 | + return response; |
| 191 | + } catch (error) { |
| 192 | + logger.error('Failed to handle info/refs', { error }); |
| 193 | + throw new Error(`Failed to get refs: ${error instanceof Error ? error.message : String(error)}`); |
| 194 | + } |
| 195 | + } |
| 196 | + |
| 197 | + /** |
| 198 | + * Handle git upload-pack request (actual clone operation) |
| 199 | + * Generates and returns packfile for git client |
| 200 | + */ |
| 201 | + static async handleUploadPack(fs: MemFS): Promise<Uint8Array> { |
| 202 | + try { |
| 203 | + // For initial implementation, send all objects |
| 204 | + // Future optimization: parse client wants from request body |
| 205 | + const head = await git.resolveRef({ fs, dir: '/', ref: 'HEAD' }); |
| 206 | + |
| 207 | + // Walk the commit tree to get all objects |
| 208 | + const { commit } = await git.readCommit({ fs, dir: '/', oid: head }); |
| 209 | + const objects = [head, commit.tree]; |
| 210 | + |
| 211 | + // Recursively collect all tree and blob objects |
| 212 | + const collectTreeObjects = async (treeOid: string): Promise<void> => { |
| 213 | + objects.push(treeOid); |
| 214 | + const { tree } = await git.readTree({ fs, dir: '/', oid: treeOid }); |
| 215 | + |
| 216 | + for (const entry of tree) { |
| 217 | + objects.push(entry.oid); |
| 218 | + if (entry.type === 'tree') { |
| 219 | + await collectTreeObjects(entry.oid); |
| 220 | + } |
| 221 | + } |
| 222 | + }; |
| 223 | + |
| 224 | + await collectTreeObjects(commit.tree); |
| 225 | + |
| 226 | + logger.info('Generating packfile', { objectCount: objects.length }); |
| 227 | + |
| 228 | + // Create packfile with all objects |
| 229 | + const packResult = await git.packObjects({ |
| 230 | + fs, |
| 231 | + dir: '/', |
| 232 | + oids: objects |
| 233 | + }); |
| 234 | + |
| 235 | + // packObjects returns { packfile: Uint8Array } |
| 236 | + const packfile = packResult.packfile; |
| 237 | + |
| 238 | + if (!packfile) { |
| 239 | + throw new Error('Failed to generate packfile'); |
| 240 | + } |
| 241 | + |
| 242 | + // Wrap packfile in sideband format for git protocol |
| 243 | + return this.wrapInSideband(packfile); |
| 244 | + } catch (error) { |
| 245 | + logger.error('Failed to handle upload-pack', { error }); |
| 246 | + throw new Error(`Failed to generate pack: ${error instanceof Error ? error.message : String(error)}`); |
| 247 | + } |
| 248 | + } |
| 249 | + |
| 250 | + /** |
| 251 | + * Format git packet line (4-byte hex length + data) |
| 252 | + */ |
| 253 | + private static formatPacketLine(data: string): string { |
| 254 | + const length = data.length + 4; |
| 255 | + const hexLength = length.toString(16).padStart(4, '0'); |
| 256 | + return hexLength + data; |
| 257 | + } |
| 258 | + |
| 259 | + /** |
| 260 | + * Wrap packfile data in sideband format |
| 261 | + * Sideband-64k protocol for multiplexing pack data and progress |
| 262 | + */ |
| 263 | + private static wrapInSideband(packfile: Uint8Array): Uint8Array { |
| 264 | + // Simple implementation: send packfile in one sideband message |
| 265 | + // Channel 1 = pack data |
| 266 | + const header = new Uint8Array([1]); // Sideband channel 1 |
| 267 | + const result = new Uint8Array(header.length + packfile.length); |
| 268 | + result.set(header, 0); |
| 269 | + result.set(packfile, header.length); |
| 270 | + return result; |
| 271 | + } |
| 272 | +} |
0 commit comments