Skip to content

Commit 6005c4d

Browse files
committed
feat: add git clone protocol support with template rebasing
1 parent 3a108c6 commit 6005c4d

10 files changed

Lines changed: 670 additions & 5 deletions

File tree

worker/agents/core/simpleGeneratorAgent.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { InferenceContext, AgentActionKey } from '../inferutils/config.types';
3434
import { AGENT_CONFIG } from '../inferutils/config';
3535
import { ModelConfigService } from '../../database/services/ModelConfigService';
3636
import { fixProjectIssues } from '../../services/code-fixer';
37+
import { GitVersionControl, GitCloneService } from '../git';
3738
import { FastCodeFixerOperation } from '../operations/PostPhaseCodeFixer';
3839
import { looksLikeCommand } from '../utils/common';
3940
import { generateBlueprint } from '../planning/blueprint';
@@ -48,7 +49,6 @@ import { ConversationMessage, ConversationState } from '../inferutils/common';
4849
import { DeepCodeDebugger } from '../assistants/codeDebugger';
4950
import { DeepDebugResult } from './types';
5051
import { StateMigration } from './stateMigration';
51-
import { GitVersionControl } from '../git';
5252

5353
interface Operations {
5454
codeReview: CodeReviewOperation;
@@ -2340,4 +2340,32 @@ export class SimpleCodeGeneratorAgent extends Agent<Env, CodeGenState> {
23402340
throw new Error(`Screenshot capture failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
23412341
}
23422342
}
2343+
2344+
/**
2345+
* Handle git info/refs request for cloning
2346+
* Returns git protocol advertisement
2347+
*/
2348+
async handleGitInfoRefs(): Promise<string> {
2349+
const fs = await GitCloneService.buildRepository({
2350+
agentGitFS: this.git.fs,
2351+
templateDetails: this.templateDetailsCache,
2352+
appQuery: this.state.query
2353+
});
2354+
2355+
return await GitCloneService.handleInfoRefs(fs);
2356+
}
2357+
2358+
/**
2359+
* Handle git upload-pack request for cloning
2360+
* Returns packfile for git clone
2361+
*/
2362+
async handleGitUploadPack(): Promise<Uint8Array> {
2363+
const fs = await GitCloneService.buildRepository({
2364+
agentGitFS: this.git.fs,
2365+
templateDetails: this.templateDetailsCache,
2366+
appQuery: this.state.query
2367+
});
2368+
2369+
return await GitCloneService.handleUploadPack(fs);
2370+
}
23432371
}
Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
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+
}

worker/agents/git/git.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export interface CommitInfo {
1616
type FileSnapshot = Omit<FileOutputType, 'filePurpose'>;
1717

1818
export class GitVersionControl {
19-
private fs: SqliteFS;
19+
public fs: SqliteFS;
2020
private author: { name: string; email: string };
2121

2222
constructor(sql: SqlExecutor, author?: { name: string; email: string }) {

worker/agents/git/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,9 @@
33
*/
44

55
export { GitVersionControl } from './git';
6+
export { GitCloneService } from './git-clone-service';
7+
export { MemFS } from './memfs';
8+
export { SqliteFS } from './fs-adapter';
69
export type { CommitInfo } from './git';
7-
export type { SqlExecutor } from './fs-adapter';
10+
export type { SqlExecutor } from './fs-adapter';
11+
export type { RepositoryBuildOptions } from './git-clone-service';

0 commit comments

Comments
 (0)