| author | |
| committer | |
| log | a21095bc9ae48f9555f6344a97834528ec6891af |
| tree | 89192c499ace997f992160bd06bb7e3444006843 |
| parent | ba03241df35b56a43c8f676a0b12c70b04ce004c |
- add `git.generateBranchName`, `git.generateAndRenameBranch`, and `git.renameBranch` websocket flows
- pass image attachments into Codex branch-name generation and normalize/sanitize results
- add GitCore branch rename with collision-safe numeric suffixing
- extract shared image MIME/data-url helpers and expand server/web tests for new behavior18 files changed, 897 insertions(+), 81 deletions(-)
apps/server/src/git/Layers/CodexTextGeneration.test.ts+96| ... | @@ -21,12 +21,23 @@ function makeFakeCodexBinary(dir: string) { | ... | @@ -21,12 +21,23 @@ function makeFakeCodexBinary(dir: string) { |
| 21 | "#!/bin/sh", | 21 | "#!/bin/sh", |
| 22 | 'output_path=""', | 22 | 'output_path=""', |
| 23 | "while [ $# -gt 0 ]; do", | 23 | "while [ $# -gt 0 ]; do", |
| 24 | ' if [ "$1" = "--image" ]; then', | ||
| 25 | " shift", | ||
| 26 | ' if [ -n "$1" ]; then', | ||
| 27 | ' seen_image="1"', | ||
| 28 | " fi", | ||
| 29 | " continue", | ||
| 30 | " fi", | ||
| 24 | ' if [ "$1" = "--output-last-message" ]; then', | 31 | ' if [ "$1" = "--output-last-message" ]; then', |
| 25 | " shift", | 32 | " shift", |
| 26 | ' output_path="$1"', | 33 | ' output_path="$1"', |
| 27 | " fi", | 34 | " fi", |
| 28 | " shift", | 35 | " shift", |
| 29 | "done", | 36 | "done", |
| 37 | 'if [ "$T3_FAKE_CODEX_REQUIRE_IMAGE" = "1" ] && [ "$seen_image" != "1" ]; then', | ||
| 38 | ' printf "%s\\n" "missing --image input" >&2', | ||
| 39 | " exit 2", | ||
| 40 | "fi", | ||
| 30 | "cat >/dev/null", | 41 | "cat >/dev/null", |
| 31 | 'if [ -n "$T3_FAKE_CODEX_STDERR" ]; then', | 42 | 'if [ -n "$T3_FAKE_CODEX_STDERR" ]; then', |
| 32 | ' printf "%s\\n" "$T3_FAKE_CODEX_STDERR" >&2', | 43 | ' printf "%s\\n" "$T3_FAKE_CODEX_STDERR" >&2', |
| ... | @@ -48,6 +59,7 @@ function withFakeCodexEnv<A, E, R>( | ... | @@ -48,6 +59,7 @@ function withFakeCodexEnv<A, E, R>( |
| 48 | output: string; | 59 | output: string; |
| 49 | exitCode?: number; | 60 | exitCode?: number; |
| 50 | stderr?: string; | 61 | stderr?: string; |
| 62 | requireImage?: boolean; | ||
| 51 | }, | 63 | }, |
| 52 | effect: Effect.Effect<A, E, R>, | 64 | effect: Effect.Effect<A, E, R>, |
| 53 | ) { | 65 | ) { |
| ... | @@ -60,6 +72,7 @@ function withFakeCodexEnv<A, E, R>( | ... | @@ -60,6 +72,7 @@ function withFakeCodexEnv<A, E, R>( |
| 60 | const previousOutput = process.env.T3_FAKE_CODEX_OUTPUT_B64; | 72 | const previousOutput = process.env.T3_FAKE_CODEX_OUTPUT_B64; |
| 61 | const previousExitCode = process.env.T3_FAKE_CODEX_EXIT_CODE; | 73 | const previousExitCode = process.env.T3_FAKE_CODEX_EXIT_CODE; |
| 62 | const previousStderr = process.env.T3_FAKE_CODEX_STDERR; | 74 | const previousStderr = process.env.T3_FAKE_CODEX_STDERR; |
| 75 | const previousRequireImage = process.env.T3_FAKE_CODEX_REQUIRE_IMAGE; | ||
| 63 | 76 | ||
| 64 | yield* Effect.sync(() => { | 77 | yield* Effect.sync(() => { |
| 65 | process.env.PATH = `${binDir}:${previousPath ?? ""}`; | 78 | process.env.PATH = `${binDir}:${previousPath ?? ""}`; |
| ... | @@ -76,6 +89,12 @@ function withFakeCodexEnv<A, E, R>( | ... | @@ -76,6 +89,12 @@ function withFakeCodexEnv<A, E, R>( |
| 76 | } else { | 89 | } else { |
| 77 | delete process.env.T3_FAKE_CODEX_STDERR; | 90 | delete process.env.T3_FAKE_CODEX_STDERR; |
| 78 | } | 91 | } |
| 92 | |||
| 93 | if (input.requireImage) { | ||
| 94 | process.env.T3_FAKE_CODEX_REQUIRE_IMAGE = "1"; | ||
| 95 | } else { | ||
| 96 | delete process.env.T3_FAKE_CODEX_REQUIRE_IMAGE; | ||
| 97 | } | ||
| 79 | }); | 98 | }); |
| 80 | 99 | ||
| 81 | return { | 100 | return { |
| ... | @@ -83,6 +102,7 @@ function withFakeCodexEnv<A, E, R>( | ... | @@ -83,6 +102,7 @@ function withFakeCodexEnv<A, E, R>( |
| 83 | previousOutput, | 102 | previousOutput, |
| 84 | previousExitCode, | 103 | previousExitCode, |
| 85 | previousStderr, | 104 | previousStderr, |
| 105 | previousRequireImage, | ||
| 86 | }; | 106 | }; |
| 87 | }), | 107 | }), |
| 88 | () => effect, | 108 | () => effect, |
| ... | @@ -107,6 +127,12 @@ function withFakeCodexEnv<A, E, R>( | ... | @@ -107,6 +127,12 @@ function withFakeCodexEnv<A, E, R>( |
| 107 | } else { | 127 | } else { |
| 108 | process.env.T3_FAKE_CODEX_STDERR = previous.previousStderr; | 128 | process.env.T3_FAKE_CODEX_STDERR = previous.previousStderr; |
| 109 | } | 129 | } |
| 130 | |||
| 131 | if (previous.previousRequireImage === undefined) { | ||
| 132 | delete process.env.T3_FAKE_CODEX_REQUIRE_IMAGE; | ||
| 133 | } else { | ||
| 134 | process.env.T3_FAKE_CODEX_REQUIRE_IMAGE = previous.previousRequireImage; | ||
| 135 | } | ||
| 110 | }), | 136 | }), |
| 111 | ); | 137 | ); |
| 112 | } | 138 | } |
| ... | @@ -170,6 +196,76 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { | ... | @@ -170,6 +196,76 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { |
| 170 | ), | 196 | ), |
| 171 | ); | 197 | ); |
| 172 | 198 | ||
| 199 | it.effect("generates branch names and normalizes branch fragments", () => | ||
| 200 | withFakeCodexEnv( | ||
| 201 | { | ||
| 202 | output: JSON.stringify({ | ||
| 203 | branch: " Feat/Session ", | ||
| 204 | }), | ||
| 205 | }, | ||
| 206 | Effect.gen(function* () { | ||
| 207 | const textGeneration = yield* TextGeneration; | ||
| 208 | |||
| 209 | const generated = yield* textGeneration.generateBranchName({ | ||
| 210 | cwd: process.cwd(), | ||
| 211 | message: "Please update session handling.", | ||
| 212 | }); | ||
| 213 | |||
| 214 | expect(generated.branch).toBe("feat/session"); | ||
| 215 | }), | ||
| 216 | ), | ||
| 217 | ); | ||
| 218 | |||
| 219 | it.effect("passes image attachments through as codex image inputs", () => | ||
| 220 | withFakeCodexEnv( | ||
| 221 | { | ||
| 222 | output: JSON.stringify({ | ||
| 223 | branch: "fix/ui-regression", | ||
| 224 | }), | ||
| 225 | requireImage: true, | ||
| 226 | }, | ||
| 227 | Effect.gen(function* () { | ||
| 228 | const textGeneration = yield* TextGeneration; | ||
| 229 | |||
| 230 | const generated = yield* textGeneration.generateBranchName({ | ||
| 231 | cwd: process.cwd(), | ||
| 232 | message: "Fix layout bug from screenshot.", | ||
| 233 | attachments: [ | ||
| 234 | { | ||
| 235 | type: "image", | ||
| 236 | name: "bug.png", | ||
| 237 | mimeType: "image/png", | ||
| 238 | sizeBytes: 5, | ||
| 239 | dataUrl: "data:image/png;base64,SGVsbG8=", | ||
| 240 | }, | ||
| 241 | ], | ||
| 242 | }); | ||
| 243 | |||
| 244 | expect(generated.branch).toBe("fix/ui-regression"); | ||
| 245 | }), | ||
| 246 | ), | ||
| 247 | ); | ||
| 248 | |||
| 249 | it.effect("returns null branch when codex returns the wrong object shape", () => | ||
| 250 | withFakeCodexEnv( | ||
| 251 | { | ||
| 252 | output: JSON.stringify({ | ||
| 253 | title: "This is not a branch payload", | ||
| 254 | }), | ||
| 255 | }, | ||
| 256 | Effect.gen(function* () { | ||
| 257 | const textGeneration = yield* TextGeneration; | ||
| 258 | |||
| 259 | const generated = yield* textGeneration.generateBranchName({ | ||
| 260 | cwd: process.cwd(), | ||
| 261 | message: "Fix websocket reconnect flake", | ||
| 262 | }); | ||
| 263 | |||
| 264 | expect(generated.branch).toBeNull(); | ||
| 265 | }), | ||
| 266 | ), | ||
| 267 | ); | ||
| 268 | |||
| 173 | it.effect("returns typed TextGenerationError when codex exits non-zero", () => | 269 | it.effect("returns typed TextGenerationError when codex exits non-zero", () => |
| 174 | withFakeCodexEnv( | 270 | withFakeCodexEnv( |
| 175 | { | 271 | { |
apps/server/src/git/Layers/CodexTextGeneration.ts+167-4| ... | @@ -4,7 +4,10 @@ import { Effect, FileSystem, Layer, Option, Path, Schema, Stream } from "effect" | ... | @@ -4,7 +4,10 @@ import { Effect, FileSystem, Layer, Option, Path, Schema, Stream } from "effect" |
| 4 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | 4 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 5 | 5 | ||
| 6 | import { TextGenerationError } from "../Errors.ts"; | 6 | import { TextGenerationError } from "../Errors.ts"; |
| 7 | import { inferImageExtension, parseBase64DataUrl } from "../../imageMime.ts"; | ||
| 7 | import { | 8 | import { |
| 9 | type BranchNameGenerationInput, | ||
| 10 | type BranchNameGenerationResult, | ||
| 8 | type CommitMessageGenerationResult, | 11 | type CommitMessageGenerationResult, |
| 9 | type PrContentGenerationResult, | 12 | type PrContentGenerationResult, |
| 10 | type TextGenerationShape, | 13 | type TextGenerationShape, |
| ... | @@ -35,6 +38,15 @@ const PR_OUTPUT_SCHEMA_JSON = { | ... | @@ -35,6 +38,15 @@ const PR_OUTPUT_SCHEMA_JSON = { |
| 35 | additionalProperties: false, | 38 | additionalProperties: false, |
| 36 | } as const; | 39 | } as const; |
| 37 | 40 | ||
| 41 | const BRANCH_NAME_OUTPUT_SCHEMA_JSON = { | ||
| 42 | type: "object", | ||
| 43 | properties: { | ||
| 44 | branch: { type: "string" }, | ||
| 45 | }, | ||
| 46 | required: ["branch"], | ||
| 47 | additionalProperties: false, | ||
| 48 | } as const; | ||
| 49 | |||
| 38 | function normalizeCodexError( | 50 | function normalizeCodexError( |
| 39 | operation: string, | 51 | operation: string, |
| 40 | error: unknown, | 52 | error: unknown, |
| ... | @@ -97,6 +109,18 @@ function parsePrOutput(raw: unknown): { title: string; body: string } { | ... | @@ -97,6 +109,18 @@ function parsePrOutput(raw: unknown): { title: string; body: string } { |
| 97 | return { title, body }; | 109 | return { title, body }; |
| 98 | } | 110 | } |
| 99 | 111 | ||
| 112 | function parseBranchNameOutput(raw: unknown): { branch: string } { | ||
| 113 | if (!raw || typeof raw !== "object") { | ||
| 114 | throw new Error("Codex returned a non-object branch payload."); | ||
| 115 | } | ||
| 116 | const record = raw as Record<string, unknown>; | ||
| 117 | const branch = typeof record.branch === "string" ? record.branch.trim() : ""; | ||
| 118 | if (branch.length === 0) { | ||
| 119 | throw new Error("Codex returned an empty branch name payload."); | ||
| 120 | } | ||
| 121 | return { branch }; | ||
| 122 | } | ||
| 123 | |||
| 100 | function limitSection(value: string, maxChars: number): string { | 124 | function limitSection(value: string, maxChars: number): string { |
| 101 | if (value.length <= maxChars) return value; | 125 | if (value.length <= maxChars) return value; |
| 102 | const truncated = value.slice(0, maxChars); | 126 | const truncated = value.slice(0, maxChars); |
| ... | @@ -124,6 +148,24 @@ function sanitizePrTitle(raw: string): string { | ... | @@ -124,6 +148,24 @@ function sanitizePrTitle(raw: string): string { |
| 124 | return "Update project changes"; | 148 | return "Update project changes"; |
| 125 | } | 149 | } |
| 126 | 150 | ||
| 151 | function sanitizeBranchName(raw: string): string { | ||
| 152 | const normalized = raw | ||
| 153 | .trim() | ||
| 154 | .toLowerCase() | ||
| 155 | .replace(/['"`]/g, "") | ||
| 156 | .replace(/^[./\s_-]+|[./\s_-]+$/g, ""); | ||
| 157 | |||
| 158 | const branchFragment = normalized | ||
| 159 | .replace(/[^a-z0-9/_-]+/g, "-") | ||
| 160 | .replace(/\/+/g, "/") | ||
| 161 | .replace(/-+/g, "-") | ||
| 162 | .replace(/^[./_-]+|[./_-]+$/g, "") | ||
| 163 | .slice(0, 64) | ||
| 164 | .replace(/[./_-]+$/g, ""); | ||
| 165 | |||
| 166 | return branchFragment.length > 0 ? branchFragment : "update"; | ||
| 167 | } | ||
| 168 | |||
| 127 | const makeCodexTextGeneration = Effect.gen(function* () { | 169 | const makeCodexTextGeneration = Effect.gen(function* () { |
| 128 | const fileSystem = yield* FileSystem.FileSystem; | 170 | const fileSystem = yield* FileSystem.FileSystem; |
| 129 | const path = yield* Path.Path; | 171 | const path = yield* Path.Path; |
| ... | @@ -168,20 +210,86 @@ const makeCodexTextGeneration = Effect.gen(function* () { | ... | @@ -168,20 +210,86 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 168 | ); | 210 | ); |
| 169 | }; | 211 | }; |
| 170 | 212 | ||
| 213 | const writeTempBinaryFile = ( | ||
| 214 | operation: string, | ||
| 215 | prefix: string, | ||
| 216 | bytes: Uint8Array, | ||
| 217 | extension: string, | ||
| 218 | ): Effect.Effect<string, TextGenerationError> => { | ||
| 219 | const normalizedExtension = extension.startsWith(".") ? extension : `.${extension}`; | ||
| 220 | const filePath = path.join( | ||
| 221 | tempDir, | ||
| 222 | `t3code-${prefix}-${process.pid}-${randomUUID()}${normalizedExtension}`, | ||
| 223 | ); | ||
| 224 | return fileSystem.writeFile(filePath, bytes).pipe( | ||
| 225 | Effect.mapError( | ||
| 226 | (cause) => | ||
| 227 | new TextGenerationError({ | ||
| 228 | operation, | ||
| 229 | detail: `Failed to write temp file at ${filePath}.`, | ||
| 230 | cause, | ||
| 231 | }), | ||
| 232 | ), | ||
| 233 | Effect.as(filePath), | ||
| 234 | ); | ||
| 235 | }; | ||
| 236 | |||
| 171 | const safeUnlink = (filePath: string): Effect.Effect<void, never> => | 237 | const safeUnlink = (filePath: string): Effect.Effect<void, never> => |
| 172 | fileSystem.remove(filePath).pipe(Effect.catch(() => Effect.void)); | 238 | fileSystem.remove(filePath).pipe(Effect.catch(() => Effect.void)); |
| 173 | 239 | ||
| 240 | const materializeImageAttachments = ( | ||
| 241 | operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName", | ||
| 242 | attachments: BranchNameGenerationInput["attachments"], | ||
| 243 | ): Effect.Effect<ReadonlyArray<string>, TextGenerationError> => | ||
| 244 | Effect.gen(function* () { | ||
| 245 | if (!attachments || attachments.length === 0) { | ||
| 246 | return []; | ||
| 247 | } | ||
| 248 | |||
| 249 | const imagePaths: string[] = []; | ||
| 250 | for (const [index, attachment] of attachments.entries()) { | ||
| 251 | if (attachment.type !== "image") { | ||
| 252 | continue; | ||
| 253 | } | ||
| 254 | |||
| 255 | const parsed = parseBase64DataUrl(attachment.dataUrl); | ||
| 256 | if (!parsed || !parsed.mimeType.startsWith("image/")) { | ||
| 257 | continue; | ||
| 258 | } | ||
| 259 | |||
| 260 | const bytes = Buffer.from(parsed.base64, "base64"); | ||
| 261 | if (bytes.byteLength === 0) { | ||
| 262 | continue; | ||
| 263 | } | ||
| 264 | |||
| 265 | const extension = inferImageExtension({ | ||
| 266 | mimeType: parsed.mimeType, | ||
| 267 | fileName: attachment.name, | ||
| 268 | }); | ||
| 269 | const imagePath = yield* writeTempBinaryFile( | ||
| 270 | operation, | ||
| 271 | `codex-image-${index}`, | ||
| 272 | bytes, | ||
| 273 | extension, | ||
| 274 | ); | ||
| 275 | imagePaths.push(imagePath); | ||
| 276 | } | ||
| 277 | return imagePaths; | ||
| 278 | }); | ||
| 279 | |||
| 174 | const runCodexJson = <T>({ | 280 | const runCodexJson = <T>({ |
| 175 | operation, | 281 | operation, |
| 176 | cwd, | 282 | cwd, |
| 177 | prompt, | 283 | prompt, |
| 178 | outputSchemaJson, | 284 | outputSchemaJson, |
| 285 | imagePaths = [], | ||
| 179 | parse, | 286 | parse, |
| 180 | }: { | 287 | }: { |
| 181 | operation: "generateCommitMessage" | "generatePrContent"; | 288 | operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName"; |
| 182 | cwd: string; | 289 | cwd: string; |
| 183 | prompt: string; | 290 | prompt: string; |
| 184 | outputSchemaJson: object; | 291 | outputSchemaJson: object; |
| 292 | imagePaths?: ReadonlyArray<string>; | ||
| 185 | parse: (raw: unknown) => T; | 293 | parse: (raw: unknown) => T; |
| 186 | }): Effect.Effect<T, TextGenerationError> => | 294 | }): Effect.Effect<T, TextGenerationError> => |
| 187 | Effect.gen(function* () { | 295 | Effect.gen(function* () { |
| ... | @@ -208,6 +316,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { | ... | @@ -208,6 +316,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 208 | schemaPath, | 316 | schemaPath, |
| 209 | "--output-last-message", | 317 | "--output-last-message", |
| 210 | outputPath, | 318 | outputPath, |
| 319 | ...imagePaths.flatMap((imagePath) => ["--image", imagePath]), | ||
| 211 | "-", | 320 | "-", |
| 212 | ], | 321 | ], |
| 213 | { | 322 | { |
| ... | @@ -254,9 +363,12 @@ const makeCodexTextGeneration = Effect.gen(function* () { | ... | @@ -254,9 +363,12 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 254 | } | 363 | } |
| 255 | }); | 364 | }); |
| 256 | 365 | ||
| 257 | const cleanup = Effect.all([safeUnlink(schemaPath), safeUnlink(outputPath)], { | 366 | const cleanup = Effect.all( |
| 258 | concurrency: "unbounded", | 367 | [schemaPath, outputPath, ...imagePaths].map((filePath) => safeUnlink(filePath)), |
| 259 | }).pipe(Effect.asVoid); | 368 | { |
| 369 | concurrency: "unbounded", | ||
| 370 | }, | ||
| 371 | ).pipe(Effect.asVoid); | ||
| 260 | 372 | ||
| 261 | return yield* Effect.gen(function* () { | 373 | return yield* Effect.gen(function* () { |
| 262 | yield* runCodexCommand.pipe( | 374 | yield* runCodexCommand.pipe( |
| ... | @@ -384,9 +496,60 @@ const makeCodexTextGeneration = Effect.gen(function* () { | ... | @@ -384,9 +496,60 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 384 | ); | 496 | ); |
| 385 | }; | 497 | }; |
| 386 | 498 | ||
| 499 | const generateBranchName: TextGenerationShape["generateBranchName"] = (input) => { | ||
| 500 | return Effect.gen(function* () { | ||
| 501 | const imagePaths = yield* materializeImageAttachments("generateBranchName", input.attachments); | ||
| 502 | const attachmentLines = (input.attachments ?? []).map( | ||
| 503 | (attachment) => `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`, | ||
| 504 | ); | ||
| 505 | |||
| 506 | const prompt = [ | ||
| 507 | "You generate concise git branch names.", | ||
| 508 | "Return a JSON object with key: branch.", | ||
| 509 | "Rules:", | ||
| 510 | "- Branch should describe the requested work from the user message.", | ||
| 511 | "- Keep it short and specific (2-6 words).", | ||
| 512 | "- Use plain words only, no issue prefixes and no punctuation-heavy text.", | ||
| 513 | "- If images are attached, use them as primary context for visual/UI issues.", | ||
| 514 | "", | ||
| 515 | `Image attachments supplied to the model: ${imagePaths.length}`, | ||
| 516 | "", | ||
| 517 | "User message:", | ||
| 518 | limitSection(input.message, 8_000), | ||
| 519 | "", | ||
| 520 | "Attachment metadata:", | ||
| 521 | attachmentLines.length > 0 ? limitSection(attachmentLines.join("\n"), 4_000) : "- none", | ||
| 522 | ].join("\n"); | ||
| 523 | |||
| 524 | const generated = yield* runCodexJson({ | ||
| 525 | operation: "generateBranchName", | ||
| 526 | cwd: input.cwd, | ||
| 527 | prompt, | ||
| 528 | outputSchemaJson: BRANCH_NAME_OUTPUT_SCHEMA_JSON, | ||
| 529 | imagePaths, | ||
| 530 | parse: (raw) => parseBranchNameOutput(raw), | ||
| 531 | }).pipe( | ||
| 532 | Effect.catch((error) => | ||
| 533 | Effect.gen(function* () { | ||
| 534 | yield* Effect.logWarning("branch-name generation failed, skipping rename", { | ||
| 535 | operation: "generateBranchName", | ||
| 536 | reason: error instanceof Error ? error.message : String(error), | ||
| 537 | }); | ||
| 538 | return { branch: null }; | ||
| 539 | }), | ||
| 540 | ), | ||
| 541 | ); | ||
| 542 | |||
| 543 | return { | ||
| 544 | branch: generated.branch ? sanitizeBranchName(generated.branch) : null, | ||
| 545 | } satisfies BranchNameGenerationResult; | ||
| 546 | }); | ||
| 547 | }; | ||
| 548 | |||
| 387 | return { | 549 | return { |
| 388 | generateCommitMessage, | 550 | generateCommitMessage, |
| 389 | generatePrContent, | 551 | generatePrContent, |
| 552 | generateBranchName, | ||
| 390 | } satisfies TextGenerationShape; | 553 | } satisfies TextGenerationShape; |
| 391 | }); | 554 | }); |
| 392 | 555 |
apps/server/src/git/Layers/GitCore.test.ts+96| ... | @@ -106,6 +106,7 @@ const makeIsolatedGitCore = (gitService: GitServiceShape) => | ... | @@ -106,6 +106,7 @@ const makeIsolatedGitCore = (gitService: GitServiceShape) => |
| 106 | listBranches: (input) => core.listBranches(input), | 106 | listBranches: (input) => core.listBranches(input), |
| 107 | createWorktree: (input) => core.createWorktree(input), | 107 | createWorktree: (input) => core.createWorktree(input), |
| 108 | removeWorktree: (input) => core.removeWorktree(input), | 108 | removeWorktree: (input) => core.removeWorktree(input), |
| 109 | renameBranch: (input) => core.renameBranch(input), | ||
| 109 | createBranch: (input) => core.createBranch(input), | 110 | createBranch: (input) => core.createBranch(input), |
| 110 | checkoutBranch: (input) => core.checkoutBranch(input), | 111 | checkoutBranch: (input) => core.checkoutBranch(input), |
| 111 | initRepo: (input) => core.initRepo(input), | 112 | initRepo: (input) => core.initRepo(input), |
| ... | @@ -154,6 +155,13 @@ function removeGitWorktree(input: Parameters<GitCoreShape["removeWorktree"]>[0]) | ... | @@ -154,6 +155,13 @@ function removeGitWorktree(input: Parameters<GitCoreShape["removeWorktree"]>[0]) |
| 154 | }); | 155 | }); |
| 155 | } | 156 | } |
| 156 | 157 | ||
| 158 | function renameGitBranch(input: Parameters<GitCoreShape["renameBranch"]>[0]) { | ||
| 159 | return Effect.gen(function* () { | ||
| 160 | const core = yield* GitCore; | ||
| 161 | return yield* core.renameBranch(input); | ||
| 162 | }); | ||
| 163 | } | ||
| 164 | |||
| 157 | function pullGitBranch({ cwd }: { cwd: string }) { | 165 | function pullGitBranch({ cwd }: { cwd: string }) { |
| 158 | return Effect.gen(function* () { | 166 | return Effect.gen(function* () { |
| 159 | const core = yield* GitCore; | 167 | const core = yield* GitCore; |
| ... | @@ -819,6 +827,94 @@ it.layer(TestLayer)("git integration", (it) => { | ... | @@ -819,6 +827,94 @@ it.layer(TestLayer)("git integration", (it) => { |
| 819 | ); | 827 | ); |
| 820 | }); | 828 | }); |
| 821 | 829 | ||
| 830 | // ── renameGitBranch ── | ||
| 831 | |||
| 832 | describe("renameGitBranch", () => { | ||
| 833 | it.effect("renames the current branch", () => | ||
| 834 | Effect.gen(function* () { | ||
| 835 | const tmp = yield* makeTmpDir(); | ||
| 836 | yield* initRepoWithCommit(tmp); | ||
| 837 | yield* createGitBranch({ cwd: tmp, branch: "feature/old-name" }); | ||
| 838 | yield* checkoutGitBranch({ cwd: tmp, branch: "feature/old-name" }); | ||
| 839 | |||
| 840 | const renamed = yield* renameGitBranch({ | ||
| 841 | cwd: tmp, | ||
| 842 | oldBranch: "feature/old-name", | ||
| 843 | newBranch: "feature/new-name", | ||
| 844 | }); | ||
| 845 | |||
| 846 | expect(renamed.branch).toBe("feature/new-name"); | ||
| 847 | |||
| 848 | const branches = yield* listGitBranches({ cwd: tmp }); | ||
| 849 | expect(branches.branches.some((branch) => branch.name === "feature/old-name")).toBe(false); | ||
| 850 | const current = branches.branches.find((branch) => branch.current); | ||
| 851 | expect(current?.name).toBe("feature/new-name"); | ||
| 852 | }), | ||
| 853 | ); | ||
| 854 | |||
| 855 | it.effect("returns success without git invocation when old/new names match", () => | ||
| 856 | Effect.gen(function* () { | ||
| 857 | const tmp = yield* makeTmpDir(); | ||
| 858 | yield* initRepoWithCommit(tmp); | ||
| 859 | const current = (yield* listGitBranches({ cwd: tmp })).branches.find((b) => b.current)!; | ||
| 860 | |||
| 861 | const renamed = yield* renameGitBranch({ | ||
| 862 | cwd: tmp, | ||
| 863 | oldBranch: current.name, | ||
| 864 | newBranch: current.name, | ||
| 865 | }); | ||
| 866 | |||
| 867 | expect(renamed.branch).toBe(current.name); | ||
| 868 | }), | ||
| 869 | ); | ||
| 870 | |||
| 871 | it.effect("appends numeric suffix when target branch already exists", () => | ||
| 872 | Effect.gen(function* () { | ||
| 873 | const tmp = yield* makeTmpDir(); | ||
| 874 | yield* initRepoWithCommit(tmp); | ||
| 875 | yield* createGitBranch({ cwd: tmp, branch: "t3code/feat/session" }); | ||
| 876 | yield* createGitBranch({ cwd: tmp, branch: "t3code/tmp-working" }); | ||
| 877 | yield* checkoutGitBranch({ cwd: tmp, branch: "t3code/tmp-working" }); | ||
| 878 | |||
| 879 | const renamed = yield* renameGitBranch({ | ||
| 880 | cwd: tmp, | ||
| 881 | oldBranch: "t3code/tmp-working", | ||
| 882 | newBranch: "t3code/feat/session", | ||
| 883 | }); | ||
| 884 | |||
| 885 | expect(renamed.branch).toBe("t3code/feat/session-1"); | ||
| 886 | const branches = yield* listGitBranches({ cwd: tmp }); | ||
| 887 | expect(branches.branches.some((branch) => branch.name === "t3code/feat/session")).toBe( | ||
| 888 | true, | ||
| 889 | ); | ||
| 890 | expect(branches.branches.some((branch) => branch.name === "t3code/feat/session-1")).toBe( | ||
| 891 | true, | ||
| 892 | ); | ||
| 893 | const current = branches.branches.find((branch) => branch.current); | ||
| 894 | expect(current?.name).toBe("t3code/feat/session-1"); | ||
| 895 | }), | ||
| 896 | ); | ||
| 897 | |||
| 898 | it.effect("increments suffix until it finds an available branch name", () => | ||
| 899 | Effect.gen(function* () { | ||
| 900 | const tmp = yield* makeTmpDir(); | ||
| 901 | yield* initRepoWithCommit(tmp); | ||
| 902 | yield* createGitBranch({ cwd: tmp, branch: "t3code/feat/session" }); | ||
| 903 | yield* createGitBranch({ cwd: tmp, branch: "t3code/feat/session-1" }); | ||
| 904 | yield* createGitBranch({ cwd: tmp, branch: "t3code/tmp-working" }); | ||
| 905 | yield* checkoutGitBranch({ cwd: tmp, branch: "t3code/tmp-working" }); | ||
| 906 | |||
| 907 | const renamed = yield* renameGitBranch({ | ||
| 908 | cwd: tmp, | ||
| 909 | oldBranch: "t3code/tmp-working", | ||
| 910 | newBranch: "t3code/feat/session", | ||
| 911 | }); | ||
| 912 | |||
| 913 | expect(renamed.branch).toBe("t3code/feat/session-2"); | ||
| 914 | }), | ||
| 915 | ); | ||
| 916 | }); | ||
| 917 | |||
| 822 | // ── createGitWorktree + removeGitWorktree ── | 918 | // ── createGitWorktree + removeGitWorktree ── |
| 823 | 919 | ||
| 824 | describe("createGitWorktree", () => { | 920 | describe("createGitWorktree", () => { |
apps/server/src/git/Layers/GitCore.ts+58| ... | @@ -250,6 +250,43 @@ const makeGitCore = Effect.gen(function* () { | ... | @@ -250,6 +250,43 @@ const makeGitCore = Effect.gen(function* () { |
| 250 | Effect.map((result) => result.stdout), | 250 | Effect.map((result) => result.stdout), |
| 251 | ); | 251 | ); |
| 252 | 252 | ||
| 253 | const branchExists = (cwd: string, branch: string): Effect.Effect<boolean, GitCommandError> => | ||
| 254 | executeGit( | ||
| 255 | "GitCore.branchExists", | ||
| 256 | cwd, | ||
| 257 | ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], | ||
| 258 | { | ||
| 259 | allowNonZeroExit: true, | ||
| 260 | timeoutMs: 5_000, | ||
| 261 | }, | ||
| 262 | ).pipe(Effect.map((result) => result.code === 0)); | ||
| 263 | |||
| 264 | const resolveAvailableBranchName = ( | ||
| 265 | cwd: string, | ||
| 266 | desiredBranch: string, | ||
| 267 | ): Effect.Effect<string, GitCommandError> => | ||
| 268 | Effect.gen(function* () { | ||
| 269 | const isDesiredTaken = yield* branchExists(cwd, desiredBranch); | ||
| 270 | if (!isDesiredTaken) { | ||
| 271 | return desiredBranch; | ||
| 272 | } | ||
| 273 | |||
| 274 | for (let suffix = 1; suffix <= 100; suffix += 1) { | ||
| 275 | const candidate = `${desiredBranch}-${suffix}`; | ||
| 276 | const isCandidateTaken = yield* branchExists(cwd, candidate); | ||
| 277 | if (!isCandidateTaken) { | ||
| 278 | return candidate; | ||
| 279 | } | ||
| 280 | } | ||
| 281 | |||
| 282 | return yield* createGitCommandError( | ||
| 283 | "GitCore.renameBranch", | ||
| 284 | cwd, | ||
| 285 | ["branch", "-m", desiredBranch], | ||
| 286 | `Could not find an available branch name for '${desiredBranch}'.`, | ||
| 287 | ); | ||
| 288 | }); | ||
| 289 | |||
| 253 | const resolveCurrentUpstream = ( | 290 | const resolveCurrentUpstream = ( |
| 254 | cwd: string, | 291 | cwd: string, |
| 255 | ): Effect.Effect< | 292 | ): Effect.Effect< |
| ... | @@ -1028,6 +1065,26 @@ const makeGitCore = Effect.gen(function* () { | ... | @@ -1028,6 +1065,26 @@ const makeGitCore = Effect.gen(function* () { |
| 1028 | ); | 1065 | ); |
| 1029 | }); | 1066 | }); |
| 1030 | 1067 | ||
| 1068 | const renameBranch: GitCoreShape["renameBranch"] = (input) => | ||
| 1069 | Effect.gen(function* () { | ||
| 1070 | if (input.oldBranch === input.newBranch) { | ||
| 1071 | return { branch: input.newBranch }; | ||
| 1072 | } | ||
| 1073 | const targetBranch = yield* resolveAvailableBranchName(input.cwd, input.newBranch); | ||
| 1074 | |||
| 1075 | yield* executeGit( | ||
| 1076 | "GitCore.renameBranch", | ||
| 1077 | input.cwd, | ||
| 1078 | ["branch", "-m", input.oldBranch, targetBranch], | ||
| 1079 | { | ||
| 1080 | timeoutMs: 10_000, | ||
| 1081 | fallbackErrorMessage: "git branch rename failed", | ||
| 1082 | }, | ||
| 1083 | ); | ||
| 1084 | |||
| 1085 | return { branch: targetBranch }; | ||
| 1086 | }); | ||
| 1087 | |||
| 1031 | const createBranch: GitCoreShape["createBranch"] = (input) => | 1088 | const createBranch: GitCoreShape["createBranch"] = (input) => |
| 1032 | executeGit("GitCore.createBranch", input.cwd, ["branch", input.branch], { | 1089 | executeGit("GitCore.createBranch", input.cwd, ["branch", input.branch], { |
| 1033 | timeoutMs: 10_000, | 1090 | timeoutMs: 10_000, |
| ... | @@ -1135,6 +1192,7 @@ const makeGitCore = Effect.gen(function* () { | ... | @@ -1135,6 +1192,7 @@ const makeGitCore = Effect.gen(function* () { |
| 1135 | listBranches, | 1192 | listBranches, |
| 1136 | createWorktree, | 1193 | createWorktree, |
| 1137 | removeWorktree, | 1194 | removeWorktree, |
| 1195 | renameBranch, | ||
| 1138 | createBranch, | 1196 | createBranch, |
| 1139 | checkoutBranch, | 1197 | checkoutBranch, |
| 1140 | initRepo, | 1198 | initRepo, |
apps/server/src/git/Layers/GitManager.test.ts+19| ... | @@ -41,6 +41,10 @@ interface FakeGitTextGeneration { | ... | @@ -41,6 +41,10 @@ interface FakeGitTextGeneration { |
| 41 | diffSummary: string; | 41 | diffSummary: string; |
| 42 | diffPatch: string; | 42 | diffPatch: string; |
| 43 | }) => Effect.Effect<{ title: string; body: string }, TextGenerationError>; | 43 | }) => Effect.Effect<{ title: string; body: string }, TextGenerationError>; |
| 44 | generateBranchName: (input: { | ||
| 45 | cwd: string; | ||
| 46 | message: string; | ||
| 47 | }) => Effect.Effect<{ branch: string }, TextGenerationError>; | ||
| 44 | } | 48 | } |
| 45 | 49 | ||
| 46 | function makeTempDir( | 50 | function makeTempDir( |
| ... | @@ -110,6 +114,10 @@ function createTextGeneration(overrides: Partial<FakeGitTextGeneration> = {}): T | ... | @@ -110,6 +114,10 @@ function createTextGeneration(overrides: Partial<FakeGitTextGeneration> = {}): T |
| 110 | title: "Add stacked git actions", | 114 | title: "Add stacked git actions", |
| 111 | body: "## Summary\n- Add stacked git workflow\n\n## Testing\n- Not run", | 115 | body: "## Summary\n- Add stacked git workflow\n\n## Testing\n- Not run", |
| 112 | }), | 116 | }), |
| 117 | generateBranchName: () => | ||
| 118 | Effect.succeed({ | ||
| 119 | branch: "update-workflow", | ||
| 120 | }), | ||
| 113 | ...overrides, | 121 | ...overrides, |
| 114 | }; | 122 | }; |
| 115 | 123 | ||
| ... | @@ -136,6 +144,17 @@ function createTextGeneration(overrides: Partial<FakeGitTextGeneration> = {}): T | ... | @@ -136,6 +144,17 @@ function createTextGeneration(overrides: Partial<FakeGitTextGeneration> = {}): T |
| 136 | }), | 144 | }), |
| 137 | ), | 145 | ), |
| 138 | ), | 146 | ), |
| 147 | generateBranchName: (input) => | ||
| 148 | implementation.generateBranchName(input).pipe( | ||
| 149 | Effect.mapError( | ||
| 150 | (cause) => | ||
| 151 | new TextGenerationError({ | ||
| 152 | operation: "generateBranchName", | ||
| 153 | detail: "fake text generation failed", | ||
| 154 | ...(cause !== undefined ? { cause } : {}), | ||
| 155 | }), | ||
| 156 | ), | ||
| 157 | ), | ||
| 139 | }; | 158 | }; |
| 140 | } | 159 | } |
| 141 | 160 |
apps/server/src/git/Services/GitCore.ts+9| ... | @@ -17,6 +17,8 @@ import type { | ... | @@ -17,6 +17,8 @@ import type { |
| 17 | GitListBranchesInput, | 17 | GitListBranchesInput, |
| 18 | GitListBranchesResult, | 18 | GitListBranchesResult, |
| 19 | GitPullResult, | 19 | GitPullResult, |
| 20 | GitRenameBranchInput, | ||
| 21 | GitRenameBranchResult, | ||
| 20 | GitRemoveWorktreeInput, | 22 | GitRemoveWorktreeInput, |
| 21 | GitStatusInput, | 23 | GitStatusInput, |
| 22 | GitStatusResult, | 24 | GitStatusResult, |
| ... | @@ -124,6 +126,13 @@ export interface GitCoreShape { | ... | @@ -124,6 +126,13 @@ export interface GitCoreShape { |
| 124 | */ | 126 | */ |
| 125 | readonly removeWorktree: (input: GitRemoveWorktreeInput) => Effect.Effect<void, GitCommandError>; | 127 | readonly removeWorktree: (input: GitRemoveWorktreeInput) => Effect.Effect<void, GitCommandError>; |
| 126 | 128 | ||
| 129 | /** | ||
| 130 | * Rename an existing local branch. | ||
| 131 | */ | ||
| 132 | readonly renameBranch: ( | ||
| 133 | input: GitRenameBranchInput, | ||
| 134 | ) => Effect.Effect<GitRenameBranchResult, GitCommandError>; | ||
| 135 | |||
| 127 | /** | 136 | /** |
| 128 | * Create a local branch. | 137 | * Create a local branch. |
| 129 | */ | 138 | */ |
apps/server/src/git/Services/TextGeneration.ts+19| ... | @@ -8,6 +8,7 @@ | ... | @@ -8,6 +8,7 @@ |
| 8 | */ | 8 | */ |
| 9 | import { ServiceMap } from "effect"; | 9 | import { ServiceMap } from "effect"; |
| 10 | import type { Effect } from "effect"; | 10 | import type { Effect } from "effect"; |
| 11 | import type { ChatAttachment } from "@t3tools/contracts"; | ||
| 11 | 12 | ||
| 12 | import type { TextGenerationError } from "../Errors.ts"; | 13 | import type { TextGenerationError } from "../Errors.ts"; |
| 13 | 14 | ||
| ... | @@ -37,11 +38,22 @@ export interface PrContentGenerationResult { | ... | @@ -37,11 +38,22 @@ export interface PrContentGenerationResult { |
| 37 | body: string; | 38 | body: string; |
| 38 | } | 39 | } |
| 39 | 40 | ||
| 41 | export interface BranchNameGenerationInput { | ||
| 42 | cwd: string; | ||
| 43 | message: string; | ||
| 44 | attachments?: ReadonlyArray<ChatAttachment> | undefined; | ||
| 45 | } | ||
| 46 | |||
| 47 | export interface BranchNameGenerationResult { | ||
| 48 | branch: string | null; | ||
| 49 | } | ||
| 50 | |||
| 40 | export interface TextGenerationService { | 51 | export interface TextGenerationService { |
| 41 | generateCommitMessage( | 52 | generateCommitMessage( |
| 42 | input: CommitMessageGenerationInput, | 53 | input: CommitMessageGenerationInput, |
| 43 | ): Promise<CommitMessageGenerationResult>; | 54 | ): Promise<CommitMessageGenerationResult>; |
| 44 | generatePrContent(input: PrContentGenerationInput): Promise<PrContentGenerationResult>; | 55 | generatePrContent(input: PrContentGenerationInput): Promise<PrContentGenerationResult>; |
| 56 | generateBranchName(input: BranchNameGenerationInput): Promise<BranchNameGenerationResult>; | ||
| 45 | } | 57 | } |
| 46 | 58 | ||
| 47 | /** | 59 | /** |
| ... | @@ -61,6 +73,13 @@ export interface TextGenerationShape { | ... | @@ -61,6 +73,13 @@ export interface TextGenerationShape { |
| 61 | readonly generatePrContent: ( | 73 | readonly generatePrContent: ( |
| 62 | input: PrContentGenerationInput, | 74 | input: PrContentGenerationInput, |
| 63 | ) => Effect.Effect<PrContentGenerationResult, TextGenerationError>; | 75 | ) => Effect.Effect<PrContentGenerationResult, TextGenerationError>; |
| 76 | |||
| 77 | /** | ||
| 78 | * Generate a concise branch name from a user message. | ||
| 79 | */ | ||
| 80 | readonly generateBranchName: ( | ||
| 81 | input: BranchNameGenerationInput, | ||
| 82 | ) => Effect.Effect<BranchNameGenerationResult, TextGenerationError>; | ||
| 64 | } | 83 | } |
| 65 | 84 | ||
| 66 | /** | 85 | /** |
apps/server/src/imageMime.ts created+64| ... | @@ -0,0 +1,64 @@ | ||
| 1 | import Mime from "@effect/platform-node/Mime"; | ||
| 2 | |||
| 3 | export const IMAGE_EXTENSION_BY_MIME_TYPE: Record<string, string> = { | ||
| 4 | "image/avif": ".avif", | ||
| 5 | "image/bmp": ".bmp", | ||
| 6 | "image/gif": ".gif", | ||
| 7 | "image/heic": ".heic", | ||
| 8 | "image/heif": ".heif", | ||
| 9 | "image/jpeg": ".jpg", | ||
| 10 | "image/jpg": ".jpg", | ||
| 11 | "image/png": ".png", | ||
| 12 | "image/svg+xml": ".svg", | ||
| 13 | "image/tiff": ".tiff", | ||
| 14 | "image/webp": ".webp", | ||
| 15 | }; | ||
| 16 | |||
| 17 | export const SAFE_IMAGE_FILE_EXTENSIONS = new Set([ | ||
| 18 | ".avif", | ||
| 19 | ".bmp", | ||
| 20 | ".gif", | ||
| 21 | ".heic", | ||
| 22 | ".heif", | ||
| 23 | ".ico", | ||
| 24 | ".jpeg", | ||
| 25 | ".jpg", | ||
| 26 | ".png", | ||
| 27 | ".svg", | ||
| 28 | ".tiff", | ||
| 29 | ".webp", | ||
| 30 | ]); | ||
| 31 | |||
| 32 | export function parseBase64DataUrl( | ||
| 33 | dataUrl: string, | ||
| 34 | ): { readonly mimeType: string; readonly base64: string } | null { | ||
| 35 | const match = /^data:([^;,]+);base64,([a-z0-9+/=\r\n]+)$/i.exec(dataUrl.trim()); | ||
| 36 | if (!match) return null; | ||
| 37 | |||
| 38 | const mimeType = match[1]?.trim().toLowerCase(); | ||
| 39 | const base64 = match[2]?.replace(/\s+/g, ""); | ||
| 40 | if (!mimeType || !base64) return null; | ||
| 41 | |||
| 42 | return { mimeType, base64 }; | ||
| 43 | } | ||
| 44 | |||
| 45 | export function inferImageExtension(input: { mimeType: string; fileName?: string }): string { | ||
| 46 | const fromMime = IMAGE_EXTENSION_BY_MIME_TYPE[input.mimeType.toLowerCase()]; | ||
| 47 | if (fromMime) { | ||
| 48 | return fromMime; | ||
| 49 | } | ||
| 50 | |||
| 51 | const fromMimeExtension = Mime.getExtension(input.mimeType); | ||
| 52 | if (fromMimeExtension && SAFE_IMAGE_FILE_EXTENSIONS.has(fromMimeExtension)) { | ||
| 53 | return fromMimeExtension; | ||
| 54 | } | ||
| 55 | |||
| 56 | const fileName = input.fileName?.trim() ?? ""; | ||
| 57 | const extensionMatch = /\.([a-z0-9]{1,8})$/i.exec(fileName); | ||
| 58 | const fileNameExtension = extensionMatch ? `.${extensionMatch[1]!.toLowerCase()}` : ""; | ||
| 59 | if (SAFE_IMAGE_FILE_EXTENSIONS.has(fileNameExtension)) { | ||
| 60 | return fileNameExtension; | ||
| 61 | } | ||
| 62 | |||
| 63 | return ".bin"; | ||
| 64 | } | ||
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+2-28| ... | @@ -1,4 +1,3 @@ | ... | @@ -1,4 +1,3 @@ |
| 1 | import Mime from "@effect/platform-node/Mime"; | ||
| 2 | import { | 1 | import { |
| 3 | ApprovalRequestId, | 2 | ApprovalRequestId, |
| 4 | type ChatAttachment, | 3 | type ChatAttachment, |
| ... | @@ -40,9 +39,8 @@ import { | ... | @@ -40,9 +39,8 @@ import { |
| 40 | } from "../Services/ProjectionPipeline.ts"; | 39 | } from "../Services/ProjectionPipeline.ts"; |
| 41 | import { | 40 | import { |
| 42 | ATTACHMENTS_ROUTE_PREFIX, | 41 | ATTACHMENTS_ROUTE_PREFIX, |
| 43 | IMAGE_EXTENSION_BY_MIME_TYPE, | ||
| 44 | SAFE_IMAGE_FILE_EXTENSIONS, | ||
| 45 | } from "../../projectFaviconRoute.ts"; | 42 | } from "../../projectFaviconRoute.ts"; |
| 43 | import { inferImageExtension, parseBase64DataUrl } from "../../imageMime.ts"; | ||
| 46 | 44 | ||
| 47 | export const ORCHESTRATION_PROJECTOR_NAMES = { | 45 | export const ORCHESTRATION_PROJECTOR_NAMES = { |
| 48 | projects: "projection.projects", | 46 | projects: "projection.projects", |
| ... | @@ -92,30 +90,6 @@ function toSafeThreadAttachmentSegment(threadId: string): string | null { | ... | @@ -92,30 +90,6 @@ function toSafeThreadAttachmentSegment(threadId: string): string | null { |
| 92 | return segment; | 90 | return segment; |
| 93 | } | 91 | } |
| 94 | 92 | ||
| 95 | function parseBase64DataUrl( | ||
| 96 | dataUrl: string, | ||
| 97 | ): { readonly mimeType: string; readonly base64: string } | null { | ||
| 98 | const match = /^data:([^;,]+);base64,([a-z0-9+/=\r\n]+)$/i.exec(dataUrl.trim()); | ||
| 99 | if (!match) return null; | ||
| 100 | |||
| 101 | const mimeType = match[1]?.trim().toLowerCase(); | ||
| 102 | const base64 = match[2]?.replace(/\s+/g, ""); | ||
| 103 | if (!mimeType || !base64) return null; | ||
| 104 | |||
| 105 | return { mimeType, base64 }; | ||
| 106 | } | ||
| 107 | |||
| 108 | function inferImageExtension(attachment: Extract<ChatAttachment, { type: "image" }>): string { | ||
| 109 | const normalizedMimeType = attachment.mimeType.toLowerCase(); | ||
| 110 | if (normalizedMimeType.startsWith("image/")) { | ||
| 111 | const fromMime = IMAGE_EXTENSION_BY_MIME_TYPE[normalizedMimeType]; | ||
| 112 | if (fromMime) return fromMime; | ||
| 113 | } | ||
| 114 | const ext = Mime.getExtension(attachment.mimeType); | ||
| 115 | if (ext && SAFE_IMAGE_FILE_EXTENSIONS.has(ext)) return ext; | ||
| 116 | return ".bin"; | ||
| 117 | } | ||
| 118 | |||
| 119 | const materializeAttachmentsForProjection = Effect.fn(function* (input: { | 93 | const materializeAttachmentsForProjection = Effect.fn(function* (input: { |
| 120 | readonly threadId: string; | 94 | readonly threadId: string; |
| 121 | readonly messageId: string; | 95 | readonly messageId: string; |
| ... | @@ -159,8 +133,8 @@ const materializeAttachmentsForProjection = Effect.fn(function* (input: { | ... | @@ -159,8 +133,8 @@ const materializeAttachmentsForProjection = Effect.fn(function* (input: { |
| 159 | if (bytes.byteLength === 0) return attachment; | 133 | if (bytes.byteLength === 0) return attachment; |
| 160 | 134 | ||
| 161 | const fileName = `${index}${inferImageExtension({ | 135 | const fileName = `${index}${inferImageExtension({ |
| 162 | ...attachment, | ||
| 163 | mimeType: parsed.mimeType, | 136 | mimeType: parsed.mimeType, |
| 137 | fileName: attachment.name, | ||
| 164 | })}`; | 138 | })}`; |
| 165 | const uniqueFileName = `${messageSegment}-${fileName}`; | 139 | const uniqueFileName = `${messageSegment}-${fileName}`; |
| 166 | const relativePath = `${threadSegment}/${uniqueFileName}`; | 140 | const relativePath = `${threadSegment}/${uniqueFileName}`; |
apps/server/src/projectFaviconRoute.ts+1-24| ... | @@ -1,33 +1,10 @@ | ... | @@ -1,33 +1,10 @@ |
| 1 | import fs from "node:fs"; | 1 | import fs from "node:fs"; |
| 2 | import http from "node:http"; | 2 | import http from "node:http"; |
| 3 | import path from "node:path"; | 3 | import path from "node:path"; |
| 4 | export { IMAGE_EXTENSION_BY_MIME_TYPE, SAFE_IMAGE_FILE_EXTENSIONS } from "./imageMime.ts"; | ||
| 4 | 5 | ||
| 5 | export const ATTACHMENTS_ROUTE_PREFIX = "/attachments"; | 6 | export const ATTACHMENTS_ROUTE_PREFIX = "/attachments"; |
| 6 | 7 | ||
| 7 | export const IMAGE_EXTENSION_BY_MIME_TYPE: Record<string, string> = { | ||
| 8 | "image/png": ".png", | ||
| 9 | "image/jpeg": ".jpg", | ||
| 10 | "image/jpg": ".jpg", | ||
| 11 | "image/gif": ".gif", | ||
| 12 | "image/webp": ".webp", | ||
| 13 | "image/svg+xml": ".svg", | ||
| 14 | "image/bmp": ".bmp", | ||
| 15 | "image/tiff": ".tiff", | ||
| 16 | "image/heic": ".heic", | ||
| 17 | }; | ||
| 18 | |||
| 19 | export const SAFE_IMAGE_FILE_EXTENSIONS = new Set([ | ||
| 20 | ".jpg", | ||
| 21 | ".jpeg", | ||
| 22 | ".png", | ||
| 23 | ".gif", | ||
| 24 | ".webp", | ||
| 25 | ".bmp", | ||
| 26 | ".tiff", | ||
| 27 | ".svg", | ||
| 28 | ".ico", | ||
| 29 | ]); | ||
| 30 | |||
| 31 | const FAVICON_MIME_TYPES: Record<string, string> = { | 8 | const FAVICON_MIME_TYPES: Record<string, string> = { |
| 32 | ".png": "image/png", | 9 | ".png": "image/png", |
| 33 | ".jpg": "image/jpeg", | 10 | ".jpg": "image/jpeg", |
apps/server/src/wsServer.test.ts+129-1| ... | @@ -46,6 +46,7 @@ import { Open, type OpenShape } from "./open"; | ... | @@ -46,6 +46,7 @@ import { Open, type OpenShape } from "./open"; |
| 46 | import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts"; | 46 | import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts"; |
| 47 | import type { GitCoreShape } from "./git/Services/GitCore.ts"; | 47 | import type { GitCoreShape } from "./git/Services/GitCore.ts"; |
| 48 | import { GitCore } from "./git/Services/GitCore.ts"; | 48 | import { GitCore } from "./git/Services/GitCore.ts"; |
| 49 | import { TextGeneration, type TextGenerationShape } from "./git/Services/TextGeneration.ts"; | ||
| 49 | import { GitCommandError, GitManagerError } from "./git/Errors.ts"; | 50 | import { GitCommandError, GitManagerError } from "./git/Errors.ts"; |
| 50 | import { MigrationError } from "@effect/sql-sqlite-bun/SqliteMigrator"; | 51 | import { MigrationError } from "@effect/sql-sqlite-bun/SqliteMigrator"; |
| 51 | 52 | ||
| ... | @@ -369,7 +370,11 @@ describe("WebSocket Server", () => { | ... | @@ -369,7 +370,11 @@ describe("WebSocket Server", () => { |
| 369 | providerLayer?: Layer.Layer<ProviderService, never>; | 370 | providerLayer?: Layer.Layer<ProviderService, never>; |
| 370 | open?: OpenShape; | 371 | open?: OpenShape; |
| 371 | gitManager?: GitManagerShape; | 372 | gitManager?: GitManagerShape; |
| 372 | gitCore?: Pick<GitCoreShape, "listBranches" | "initRepo" | "pullCurrentBranch">; | 373 | gitCore?: Pick< |
| 374 | GitCoreShape, | ||
| 375 | "listBranches" | "initRepo" | "pullCurrentBranch" | "renameBranch" | ||
| 376 | >; | ||
| 377 | textGeneration?: Pick<TextGenerationShape, "generateBranchName">; | ||
| 373 | terminalManager?: TerminalManagerShape; | 378 | terminalManager?: TerminalManagerShape; |
| 374 | } = {}, | 379 | } = {}, |
| 375 | ): Promise<Http.Server> { | 380 | ): Promise<Http.Server> { |
| ... | @@ -402,6 +407,9 @@ describe("WebSocket Server", () => { | ... | @@ -402,6 +407,9 @@ describe("WebSocket Server", () => { |
| 402 | options.gitCore | 407 | options.gitCore |
| 403 | ? Layer.succeed(GitCore, options.gitCore as unknown as GitCoreShape) | 408 | ? Layer.succeed(GitCore, options.gitCore as unknown as GitCoreShape) |
| 404 | : Layer.empty, | 409 | : Layer.empty, |
| 410 | options.textGeneration | ||
| 411 | ? Layer.succeed(TextGeneration, options.textGeneration as unknown as TextGenerationShape) | ||
| 412 | : Layer.empty, | ||
| 405 | options.terminalManager | 413 | options.terminalManager |
| 406 | ? Layer.succeed(TerminalManager, options.terminalManager) | 414 | ? Layer.succeed(TerminalManager, options.terminalManager) |
| 407 | : Layer.empty, | 415 | : Layer.empty, |
| ... | @@ -1468,6 +1476,8 @@ describe("WebSocket Server", () => { | ... | @@ -1468,6 +1476,8 @@ describe("WebSocket Server", () => { |
| 1468 | }), | 1476 | }), |
| 1469 | ); | 1477 | ); |
| 1470 | const initRepo = vi.fn(() => Effect.void); | 1478 | const initRepo = vi.fn(() => Effect.void); |
| 1479 | const renameBranch = vi.fn(() => Effect.succeed({ branch: "t3code/new-name" })); | ||
| 1480 | const generateBranchName = vi.fn(() => Effect.succeed({ branch: "new-name" as const })); | ||
| 1471 | const pullCurrentBranch = vi.fn(() => | 1481 | const pullCurrentBranch = vi.fn(() => |
| 1472 | Effect.fail( | 1482 | Effect.fail( |
| 1473 | new GitCommandError({ | 1483 | new GitCommandError({ |
| ... | @@ -1484,8 +1494,12 @@ describe("WebSocket Server", () => { | ... | @@ -1484,8 +1494,12 @@ describe("WebSocket Server", () => { |
| 1484 | gitCore: { | 1494 | gitCore: { |
| 1485 | listBranches, | 1495 | listBranches, |
| 1486 | initRepo, | 1496 | initRepo, |
| 1497 | renameBranch, | ||
| 1487 | pullCurrentBranch, | 1498 | pullCurrentBranch, |
| 1488 | }, | 1499 | }, |
| 1500 | textGeneration: { | ||
| 1501 | generateBranchName, | ||
| 1502 | }, | ||
| 1489 | }); | 1503 | }); |
| 1490 | const addr = server.address(); | 1504 | const addr = server.address(); |
| 1491 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | 1505 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| ... | @@ -1507,6 +1521,120 @@ describe("WebSocket Server", () => { | ... | @@ -1507,6 +1521,120 @@ describe("WebSocket Server", () => { |
| 1507 | expect(pullResponse.result).toBeUndefined(); | 1521 | expect(pullResponse.result).toBeUndefined(); |
| 1508 | expect(pullResponse.error?.message).toContain("No upstream configured"); | 1522 | expect(pullResponse.error?.message).toContain("No upstream configured"); |
| 1509 | expect(pullCurrentBranch).toHaveBeenCalledWith("/repo/path"); | 1523 | expect(pullCurrentBranch).toHaveBeenCalledWith("/repo/path"); |
| 1524 | |||
| 1525 | const generatedBranchResponse = await sendRequest(ws, WS_METHODS.gitGenerateBranchName, { | ||
| 1526 | cwd: "/repo/path", | ||
| 1527 | message: "Add retry logic for websocket reconnects", | ||
| 1528 | attachments: [ | ||
| 1529 | { | ||
| 1530 | type: "image", | ||
| 1531 | name: "bug.png", | ||
| 1532 | mimeType: "image/png", | ||
| 1533 | sizeBytes: 5, | ||
| 1534 | dataUrl: "data:image/png;base64,SGVsbG8=", | ||
| 1535 | }, | ||
| 1536 | ], | ||
| 1537 | }); | ||
| 1538 | expect(generatedBranchResponse.error).toBeUndefined(); | ||
| 1539 | expect(generatedBranchResponse.result).toEqual({ branch: "new-name" }); | ||
| 1540 | expect(generateBranchName).toHaveBeenCalledWith({ | ||
| 1541 | cwd: "/repo/path", | ||
| 1542 | message: "Add retry logic for websocket reconnects", | ||
| 1543 | attachments: [ | ||
| 1544 | { | ||
| 1545 | type: "image", | ||
| 1546 | name: "bug.png", | ||
| 1547 | mimeType: "image/png", | ||
| 1548 | sizeBytes: 5, | ||
| 1549 | dataUrl: "data:image/png;base64,SGVsbG8=", | ||
| 1550 | }, | ||
| 1551 | ], | ||
| 1552 | }); | ||
| 1553 | |||
| 1554 | const generateAndRenameResponse = await sendRequest( | ||
| 1555 | ws, | ||
| 1556 | WS_METHODS.gitGenerateAndRenameBranch, | ||
| 1557 | { | ||
| 1558 | cwd: "/repo/path", | ||
| 1559 | oldBranch: "t3code/tmp-123", | ||
| 1560 | message: "Add retry logic for websocket reconnects", | ||
| 1561 | attachments: [ | ||
| 1562 | { | ||
| 1563 | type: "image", | ||
| 1564 | name: "bug.png", | ||
| 1565 | mimeType: "image/png", | ||
| 1566 | sizeBytes: 5, | ||
| 1567 | dataUrl: "data:image/png;base64,SGVsbG8=", | ||
| 1568 | }, | ||
| 1569 | ], | ||
| 1570 | }, | ||
| 1571 | ); | ||
| 1572 | expect(generateAndRenameResponse.error).toBeUndefined(); | ||
| 1573 | expect(generateAndRenameResponse.result).toEqual({ branch: "t3code/new-name" }); | ||
| 1574 | expect(renameBranch).toHaveBeenCalledWith({ | ||
| 1575 | cwd: "/repo/path", | ||
| 1576 | oldBranch: "t3code/tmp-123", | ||
| 1577 | newBranch: "t3code/new-name", | ||
| 1578 | }); | ||
| 1579 | |||
| 1580 | const renameResponse = await sendRequest(ws, WS_METHODS.gitRenameBranch, { | ||
| 1581 | cwd: "/repo/path", | ||
| 1582 | oldBranch: "t3code/tmp-456", | ||
| 1583 | newBranch: "t3code/new-name", | ||
| 1584 | }); | ||
| 1585 | expect(renameResponse.error).toBeUndefined(); | ||
| 1586 | expect(renameResponse.result).toEqual({ branch: "t3code/new-name" }); | ||
| 1587 | expect(renameBranch).toHaveBeenCalledWith({ | ||
| 1588 | cwd: "/repo/path", | ||
| 1589 | oldBranch: "t3code/tmp-456", | ||
| 1590 | newBranch: "t3code/new-name", | ||
| 1591 | }); | ||
| 1592 | }); | ||
| 1593 | |||
| 1594 | it("skips rename in git.generateAndRenameBranch when branch generation returns null", async () => { | ||
| 1595 | const renameBranch = vi.fn(() => Effect.succeed({ branch: "t3code/new-name" })); | ||
| 1596 | const generateBranchName = vi.fn(() => Effect.succeed({ branch: null })); | ||
| 1597 | |||
| 1598 | server = await createTestServer({ | ||
| 1599 | cwd: "/test", | ||
| 1600 | gitCore: { | ||
| 1601 | listBranches: () => | ||
| 1602 | Effect.succeed({ | ||
| 1603 | branches: [], | ||
| 1604 | isRepo: false, | ||
| 1605 | }), | ||
| 1606 | initRepo: () => Effect.void, | ||
| 1607 | renameBranch, | ||
| 1608 | pullCurrentBranch: () => | ||
| 1609 | Effect.fail( | ||
| 1610 | new GitCommandError({ | ||
| 1611 | operation: "GitCore.test.pullCurrentBranch", | ||
| 1612 | detail: "No upstream configured", | ||
| 1613 | command: "git pull", | ||
| 1614 | cwd: "/repo/path", | ||
| 1615 | }), | ||
| 1616 | ), | ||
| 1617 | }, | ||
| 1618 | textGeneration: { | ||
| 1619 | generateBranchName, | ||
| 1620 | }, | ||
| 1621 | }); | ||
| 1622 | const addr = server.address(); | ||
| 1623 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | ||
| 1624 | const ws = await connectWs(port); | ||
| 1625 | connections.push(ws); | ||
| 1626 | await waitForMessage(ws); | ||
| 1627 | |||
| 1628 | const response = await sendRequest(ws, WS_METHODS.gitGenerateAndRenameBranch, { | ||
| 1629 | cwd: "/repo/path", | ||
| 1630 | oldBranch: "t3code/tmp-123", | ||
| 1631 | message: "Fix visual regression", | ||
| 1632 | attachments: [], | ||
| 1633 | }); | ||
| 1634 | |||
| 1635 | expect(response.error).toBeUndefined(); | ||
| 1636 | expect(response.result).toEqual({ branch: null }); | ||
| 1637 | expect(renameBranch).not.toHaveBeenCalled(); | ||
| 1510 | }); | 1638 | }); |
| 1511 | 1639 | ||
| 1512 | it("supports git.status over websocket", async () => { | 1640 | it("supports git.status over websocket", async () => { |
apps/server/src/wsServer.ts+59| ... | @@ -54,6 +54,7 @@ import { clamp } from "effect/Number"; | ... | @@ -54,6 +54,7 @@ import { clamp } from "effect/Number"; |
| 54 | import { Open } from "./open"; | 54 | import { Open } from "./open"; |
| 55 | import { ServerConfig } from "./config"; | 55 | import { ServerConfig } from "./config"; |
| 56 | import { GitCore } from "./git/Services/GitCore.ts"; | 56 | import { GitCore } from "./git/Services/GitCore.ts"; |
| 57 | import { TextGeneration } from "./git/Services/TextGeneration.ts"; | ||
| 57 | import { ATTACHMENTS_ROUTE_PREFIX, tryHandleProjectFaviconRequest } from "./projectFaviconRoute"; | 58 | import { ATTACHMENTS_ROUTE_PREFIX, tryHandleProjectFaviconRequest } from "./projectFaviconRoute"; |
| 58 | 59 | ||
| 59 | /** | 60 | /** |
| ... | @@ -135,6 +136,31 @@ function stripRequestTag<T extends { _tag: string }>(body: T) { | ... | @@ -135,6 +136,31 @@ function stripRequestTag<T extends { _tag: string }>(body: T) { |
| 135 | return Struct.omit(body, ["_tag"]); | 136 | return Struct.omit(body, ["_tag"]); |
| 136 | } | 137 | } |
| 137 | 138 | ||
| 139 | const WORKTREE_BRANCH_PREFIX = "t3code"; | ||
| 140 | |||
| 141 | function buildGeneratedWorktreeBranchName(raw: string): string { | ||
| 142 | const normalized = raw | ||
| 143 | .trim() | ||
| 144 | .toLowerCase() | ||
| 145 | .replace(/^refs\/heads\//, "") | ||
| 146 | .replace(/['"`]/g, ""); | ||
| 147 | |||
| 148 | const withoutPrefix = normalized.startsWith(`${WORKTREE_BRANCH_PREFIX}/`) | ||
| 149 | ? normalized.slice(`${WORKTREE_BRANCH_PREFIX}/`.length) | ||
| 150 | : normalized; | ||
| 151 | |||
| 152 | const branchFragment = withoutPrefix | ||
| 153 | .replace(/[^a-z0-9/_-]+/g, "-") | ||
| 154 | .replace(/\/+/g, "/") | ||
| 155 | .replace(/-+/g, "-") | ||
| 156 | .replace(/^[./_-]+|[./_-]+$/g, "") | ||
| 157 | .slice(0, 64) | ||
| 158 | .replace(/[./_-]+$/g, ""); | ||
| 159 | |||
| 160 | const safeFragment = branchFragment.length > 0 ? branchFragment : "update"; | ||
| 161 | return `${WORKTREE_BRANCH_PREFIX}/${safeFragment}`; | ||
| 162 | } | ||
| 163 | |||
| 138 | export type ServerCoreRuntimeServices = | 164 | export type ServerCoreRuntimeServices = |
| 139 | | OrchestrationEngineService | 165 | | OrchestrationEngineService |
| 140 | | ProjectionSnapshotQuery | 166 | | ProjectionSnapshotQuery |
| ... | @@ -146,6 +172,7 @@ export type ServerRuntimeServices = | ... | @@ -146,6 +172,7 @@ export type ServerRuntimeServices = |
| 146 | | ServerCoreRuntimeServices | 172 | | ServerCoreRuntimeServices |
| 147 | | GitManager | 173 | | GitManager |
| 148 | | GitCore | 174 | | GitCore |
| 175 | | TextGeneration | ||
| 149 | | TerminalManager | 176 | | TerminalManager |
| 150 | | Keybindings | 177 | | Keybindings |
| 151 | | Open; | 178 | | Open; |
| ... | @@ -184,6 +211,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< | ... | @@ -184,6 +211,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 184 | const terminalManager = yield* TerminalManager; | 211 | const terminalManager = yield* TerminalManager; |
| 185 | const keybindingsManager = yield* Keybindings; | 212 | const keybindingsManager = yield* Keybindings; |
| 186 | const git = yield* GitCore; | 213 | const git = yield* GitCore; |
| 214 | const textGeneration = yield* TextGeneration; | ||
| 187 | const fileSystem = yield* FileSystem.FileSystem; | 215 | const fileSystem = yield* FileSystem.FileSystem; |
| 188 | const path = yield* Path.Path; | 216 | const path = yield* Path.Path; |
| 189 | 217 | ||
| ... | @@ -572,6 +600,37 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< | ... | @@ -572,6 +600,37 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 572 | return yield* git.createWorktree(body); | 600 | return yield* git.createWorktree(body); |
| 573 | } | 601 | } |
| 574 | 602 | ||
| 603 | case WS_METHODS.gitGenerateBranchName: { | ||
| 604 | const body = stripRequestTag(request.body); | ||
| 605 | return yield* textGeneration.generateBranchName(body); | ||
| 606 | } | ||
| 607 | |||
| 608 | case WS_METHODS.gitGenerateAndRenameBranch: { | ||
| 609 | const body = stripRequestTag(request.body); | ||
| 610 | const generated = yield* textGeneration.generateBranchName({ | ||
| 611 | cwd: body.cwd, | ||
| 612 | message: body.message, | ||
| 613 | ...(body.attachments !== undefined ? { attachments: body.attachments } : {}), | ||
| 614 | }); | ||
| 615 | if (!generated.branch) { | ||
| 616 | return { branch: null }; | ||
| 617 | } | ||
| 618 | const targetBranch = buildGeneratedWorktreeBranchName(generated.branch); | ||
| 619 | if (targetBranch === body.oldBranch) { | ||
| 620 | return { branch: body.oldBranch }; | ||
| 621 | } | ||
| 622 | return yield* git.renameBranch({ | ||
| 623 | cwd: body.cwd, | ||
| 624 | oldBranch: body.oldBranch, | ||
| 625 | newBranch: targetBranch, | ||
| 626 | }); | ||
| 627 | } | ||
| 628 | |||
| 629 | case WS_METHODS.gitRenameBranch: { | ||
| 630 | const body = stripRequestTag(request.body); | ||
| 631 | return yield* git.renameBranch(body); | ||
| 632 | } | ||
| 633 | |||
| 575 | case WS_METHODS.gitRemoveWorktree: { | 634 | case WS_METHODS.gitRemoveWorktree: { |
| 576 | const body = stripRequestTag(request.body); | 635 | const body = stripRequestTag(request.body); |
| 577 | return yield* git.removeWorktree(body); | 636 | return yield* git.removeWorktree(body); |
apps/web/src/components/ChatView.tsx+108-20| ... | @@ -129,6 +129,7 @@ const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; | ... | @@ -129,6 +129,7 @@ const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; |
| 129 | const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; | 129 | const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; |
| 130 | const SCRIPT_TERMINAL_COLS = 120; | 130 | const SCRIPT_TERMINAL_COLS = 120; |
| 131 | const SCRIPT_TERMINAL_ROWS = 30; | 131 | const SCRIPT_TERMINAL_ROWS = 30; |
| 132 | const WORKTREE_BRANCH_PREFIX = "t3code"; | ||
| 132 | 133 | ||
| 133 | function readLastInvokedScriptByProjectFromStorage(): Record<string, string> { | 134 | function readLastInvokedScriptByProjectFromStorage(): Record<string, string> { |
| 134 | const stored = localStorage.getItem(LAST_INVOKED_SCRIPT_BY_PROJECT_KEY); | 135 | const stored = localStorage.getItem(LAST_INVOKED_SCRIPT_BY_PROJECT_KEY); |
| ... | @@ -232,6 +233,57 @@ function readFileAsDataUrl(file: File): Promise<string> { | ... | @@ -232,6 +233,57 @@ function readFileAsDataUrl(file: File): Promise<string> { |
| 232 | }); | 233 | }); |
| 233 | } | 234 | } |
| 234 | 235 | ||
| 236 | function hashBranchSeed(value: string): string { | ||
| 237 | let hash = 2166136261; | ||
| 238 | for (let index = 0; index < value.length; index++) { | ||
| 239 | hash ^= value.charCodeAt(index); | ||
| 240 | hash = Math.imul(hash, 16777619); | ||
| 241 | } | ||
| 242 | return (hash >>> 0).toString(16).padStart(8, "0"); | ||
| 243 | } | ||
| 244 | |||
| 245 | function buildTemporaryWorktreeBranchName(message: string): string { | ||
| 246 | const seed = `${Date.now()}:${message.trim().toLowerCase()}:${crypto.randomUUID()}`; | ||
| 247 | return `${WORKTREE_BRANCH_PREFIX}/${hashBranchSeed(seed)}`; | ||
| 248 | } | ||
| 249 | |||
| 250 | function imageReferenceForBranchNaming(image: ComposerImageAttachment): string { | ||
| 251 | const maybePath = (image.file as File & { path?: unknown }).path; | ||
| 252 | if (typeof maybePath === "string" && maybePath.trim().length > 0) { | ||
| 253 | return maybePath.trim(); | ||
| 254 | } | ||
| 255 | return image.name; | ||
| 256 | } | ||
| 257 | |||
| 258 | function buildBranchNameGenerationMessage( | ||
| 259 | text: string, | ||
| 260 | images: ReadonlyArray<ComposerImageAttachment>, | ||
| 261 | ): string { | ||
| 262 | const trimmedText = text.trim(); | ||
| 263 | const imageReferences = Array.from( | ||
| 264 | new Set( | ||
| 265 | images | ||
| 266 | .map((image) => imageReferenceForBranchNaming(image).trim()) | ||
| 267 | .filter((reference) => reference.length > 0), | ||
| 268 | ), | ||
| 269 | ); | ||
| 270 | const attachmentsSection = | ||
| 271 | imageReferences.length > 0 | ||
| 272 | ? `Attached images:\n${imageReferences.map((reference) => `- ${reference}`).join("\n")}` | ||
| 273 | : ""; | ||
| 274 | |||
| 275 | if (trimmedText.length > 0 && attachmentsSection.length > 0) { | ||
| 276 | return `${trimmedText}\n\n${attachmentsSection}`; | ||
| 277 | } | ||
| 278 | if (trimmedText.length > 0) { | ||
| 279 | return trimmedText; | ||
| 280 | } | ||
| 281 | if (attachmentsSection.length > 0) { | ||
| 282 | return attachmentsSection; | ||
| 283 | } | ||
| 284 | return "New worktree change"; | ||
| 285 | } | ||
| 286 | |||
| 235 | const VscodeEntryIcon = memo(function VscodeEntryIcon(props: { | 287 | const VscodeEntryIcon = memo(function VscodeEntryIcon(props: { |
| 236 | pathValue: string; | 288 | pathValue: string; |
| 237 | kind: "file" | "directory"; | 289 | kind: "file" | "directory"; |
| ... | @@ -1478,6 +1530,26 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1478,6 +1530,26 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1478 | const composerImagesSnapshot = [...composerImages]; | 1530 | const composerImagesSnapshot = [...composerImages]; |
| 1479 | const messageIdForSend = newMessageId(); | 1531 | const messageIdForSend = newMessageId(); |
| 1480 | const messageCreatedAt = new Date().toISOString(); | 1532 | const messageCreatedAt = new Date().toISOString(); |
| 1533 | const branchNameMessage = buildBranchNameGenerationMessage(trimmed, composerImagesSnapshot); | ||
| 1534 | const turnAttachmentsPromise = Promise.all( | ||
| 1535 | composerImagesSnapshot.map( | ||
| 1536 | async ( | ||
| 1537 | image, | ||
| 1538 | ): Promise<{ | ||
| 1539 | type: "image"; | ||
| 1540 | name: string; | ||
| 1541 | mimeType: string; | ||
| 1542 | sizeBytes: number; | ||
| 1543 | dataUrl: string; | ||
| 1544 | }> => ({ | ||
| 1545 | type: "image", | ||
| 1546 | name: image.name, | ||
| 1547 | mimeType: image.mimeType, | ||
| 1548 | sizeBytes: image.sizeBytes, | ||
| 1549 | dataUrl: await readFileAsDataUrl(image.file), | ||
| 1550 | }), | ||
| 1551 | ), | ||
| 1552 | ); | ||
| 1481 | const optimisticAttachments = composerImagesSnapshot.map((image) => ({ | 1553 | const optimisticAttachments = composerImagesSnapshot.map((image) => ({ |
| 1482 | type: "image" as const, | 1554 | type: "image" as const, |
| 1483 | id: image.id, | 1555 | id: image.id, |
| ... | @@ -1506,11 +1578,12 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1506,11 +1578,12 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1506 | setComposerHighlightedItemId(null); | 1578 | setComposerHighlightedItemId(null); |
| 1507 | 1579 | ||
| 1508 | let attemptedTurnStart = false; | 1580 | let attemptedTurnStart = false; |
| 1581 | let pendingWorktreeBranchRename: Promise<void> | null = null; | ||
| 1509 | try { | 1582 | try { |
| 1510 | // On first message: lock in branch + create worktree if needed. | 1583 | // On first message: lock in branch + create worktree if needed. |
| 1511 | if (baseBranchForWorktree) { | 1584 | if (baseBranchForWorktree) { |
| 1512 | setSendPhase("preparing-worktree"); | 1585 | setSendPhase("preparing-worktree"); |
| 1513 | const newBranch = `codething/${crypto.randomUUID().slice(0, 8)}`; | 1586 | const newBranch = buildTemporaryWorktreeBranchName(trimmed); |
| 1514 | const result = await createWorktreeMutation.mutateAsync({ | 1587 | const result = await createWorktreeMutation.mutateAsync({ |
| 1515 | cwd: activeProject.cwd, | 1588 | cwd: activeProject.cwd, |
| 1516 | branch: baseBranchForWorktree, | 1589 | branch: baseBranchForWorktree, |
| ... | @@ -1531,6 +1604,36 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1531,6 +1604,36 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1531 | branch: result.worktree.branch, | 1604 | branch: result.worktree.branch, |
| 1532 | worktreePath: result.worktree.path, | 1605 | worktreePath: result.worktree.path, |
| 1533 | }); | 1606 | }); |
| 1607 | pendingWorktreeBranchRename = (async () => { | ||
| 1608 | const turnAttachments = await turnAttachmentsPromise; | ||
| 1609 | const renamed = await api.git.generateAndRenameBranch({ | ||
| 1610 | cwd: result.worktree.path, | ||
| 1611 | oldBranch: result.worktree.branch, | ||
| 1612 | message: branchNameMessage, | ||
| 1613 | attachments: turnAttachments, | ||
| 1614 | }); | ||
| 1615 | if (!renamed.branch || renamed.branch === result.worktree.branch) { | ||
| 1616 | return; | ||
| 1617 | } | ||
| 1618 | await api.orchestration.dispatchCommand({ | ||
| 1619 | type: "thread.meta.update", | ||
| 1620 | commandId: newCommandId(), | ||
| 1621 | threadId: threadIdForSend, | ||
| 1622 | branch: renamed.branch, | ||
| 1623 | worktreePath: result.worktree.path, | ||
| 1624 | }); | ||
| 1625 | dispatch({ | ||
| 1626 | type: "SET_THREAD_BRANCH", | ||
| 1627 | threadId: threadIdForSend, | ||
| 1628 | branch: renamed.branch, | ||
| 1629 | worktreePath: result.worktree.path, | ||
| 1630 | }); | ||
| 1631 | })().catch((error) => { | ||
| 1632 | console.warn("Failed to rename generated worktree branch", { | ||
| 1633 | threadId: threadIdForSend, | ||
| 1634 | error: error instanceof Error ? error.message : String(error), | ||
| 1635 | }); | ||
| 1636 | }); | ||
| 1534 | const setupScript = setupProjectScript(activeProject.scripts); | 1637 | const setupScript = setupProjectScript(activeProject.scripts); |
| 1535 | if (setupScript) { | 1638 | if (setupScript) { |
| 1536 | await runProjectScript(setupScript, { | 1639 | await runProjectScript(setupScript, { |
| ... | @@ -1558,25 +1661,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1558,25 +1661,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1558 | } | 1661 | } |
| 1559 | 1662 | ||
| 1560 | setSendPhase("sending-turn"); | 1663 | setSendPhase("sending-turn"); |
| 1561 | const turnAttachments = await Promise.all( | 1664 | const turnAttachments = await turnAttachmentsPromise; |
| 1562 | composerImagesSnapshot.map( | ||
| 1563 | async ( | ||
| 1564 | image, | ||
| 1565 | ): Promise<{ | ||
| 1566 | type: "image"; | ||
| 1567 | name: string; | ||
| 1568 | mimeType: string; | ||
| 1569 | sizeBytes: number; | ||
| 1570 | dataUrl: string; | ||
| 1571 | }> => ({ | ||
| 1572 | type: "image", | ||
| 1573 | name: image.name, | ||
| 1574 | mimeType: image.mimeType, | ||
| 1575 | sizeBytes: image.sizeBytes, | ||
| 1576 | dataUrl: await readFileAsDataUrl(image.file), | ||
| 1577 | }), | ||
| 1578 | ), | ||
| 1579 | ); | ||
| 1580 | attemptedTurnStart = true; | 1665 | attemptedTurnStart = true; |
| 1581 | const approvalPolicy = state.runtimeMode === "full-access" ? "never" : "on-request"; | 1666 | const approvalPolicy = state.runtimeMode === "full-access" ? "never" : "on-request"; |
| 1582 | const sandboxMode = | 1667 | const sandboxMode = |
| ... | @@ -1598,6 +1683,9 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1598,6 +1683,9 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1598 | sandboxMode, | 1683 | sandboxMode, |
| 1599 | createdAt: messageCreatedAt, | 1684 | createdAt: messageCreatedAt, |
| 1600 | }); | 1685 | }); |
| 1686 | if (pendingWorktreeBranchRename) { | ||
| 1687 | void pendingWorktreeBranchRename; | ||
| 1688 | } | ||
| 1601 | } catch (err) { | 1689 | } catch (err) { |
| 1602 | if ( | 1690 | if ( |
| 1603 | !attemptedTurnStart && | 1691 | !attemptedTurnStart && |
apps/web/src/worktreeCleanup.test.ts+4-4| ... | @@ -88,16 +88,16 @@ describe("getOrphanedWorktreePathForThread", () => { | ... | @@ -88,16 +88,16 @@ describe("getOrphanedWorktreePathForThread", () => { |
| 88 | describe("formatWorktreePathForDisplay", () => { | 88 | describe("formatWorktreePathForDisplay", () => { |
| 89 | it("shows only the last path segment for unix-like paths", () => { | 89 | it("shows only the last path segment for unix-like paths", () => { |
| 90 | const result = formatWorktreePathForDisplay( | 90 | const result = formatWorktreePathForDisplay( |
| 91 | "/Users/julius/.t3/worktrees/codething-mvp/codething-4e609bb8", | 91 | "/Users/julius/.t3/worktrees/t3code-mvp/t3code-4e609bb8", |
| 92 | ); | 92 | ); |
| 93 | expect(result).toBe("codething-4e609bb8"); | 93 | expect(result).toBe("t3code-4e609bb8"); |
| 94 | }); | 94 | }); |
| 95 | 95 | ||
| 96 | it("normalizes windows separators before selecting the final segment", () => { | 96 | it("normalizes windows separators before selecting the final segment", () => { |
| 97 | const result = formatWorktreePathForDisplay( | 97 | const result = formatWorktreePathForDisplay( |
| 98 | "C:\\Users\\julius\\.t3\\worktrees\\codething-mvp\\codething-4e609bb8", | 98 | "C:\\Users\\julius\\.t3\\worktrees\\t3code-mvp\\t3code-4e609bb8", |
| 99 | ); | 99 | ); |
| 100 | expect(result).toBe("codething-4e609bb8"); | 100 | expect(result).toBe("t3code-4e609bb8"); |
| 101 | }); | 101 | }); |
| 102 | 102 | ||
| 103 | it("uses the final segment even when outside ~/.t3/worktrees", () => { | 103 | it("uses the final segment even when outside ~/.t3/worktrees", () => { |
apps/web/src/wsNativeApi.ts+4| ... | @@ -164,6 +164,10 @@ export function createWsNativeApi(): NativeApi { | ... | @@ -164,6 +164,10 @@ export function createWsNativeApi(): NativeApi { |
| 164 | runStackedAction: (input) => transport.request(WS_METHODS.gitRunStackedAction, input), | 164 | runStackedAction: (input) => transport.request(WS_METHODS.gitRunStackedAction, input), |
| 165 | listBranches: (input) => transport.request(WS_METHODS.gitListBranches, input), | 165 | listBranches: (input) => transport.request(WS_METHODS.gitListBranches, input), |
| 166 | createWorktree: (input) => transport.request(WS_METHODS.gitCreateWorktree, input), | 166 | createWorktree: (input) => transport.request(WS_METHODS.gitCreateWorktree, input), |
| 167 | generateBranchName: (input) => transport.request(WS_METHODS.gitGenerateBranchName, input), | ||
| 168 | generateAndRenameBranch: (input) => | ||
| 169 | transport.request(WS_METHODS.gitGenerateAndRenameBranch, input), | ||
| 170 | renameBranch: (input) => transport.request(WS_METHODS.gitRenameBranch, input), | ||
| 167 | removeWorktree: (input) => transport.request(WS_METHODS.gitRemoveWorktree, input), | 171 | removeWorktree: (input) => transport.request(WS_METHODS.gitRemoveWorktree, input), |
| 168 | createBranch: (input) => transport.request(WS_METHODS.gitCreateBranch, input), | 172 | createBranch: (input) => transport.request(WS_METHODS.gitCreateBranch, input), |
| 169 | checkout: (input) => transport.request(WS_METHODS.gitCheckout, input), | 173 | checkout: (input) => transport.request(WS_METHODS.gitCheckout, input), |
packages/contracts/src/git.ts+42| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | import { Schema } from "effect"; | 1 | import { Schema } from "effect"; |
| 2 | import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas"; | 2 | import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas"; |
| 3 | import { ChatAttachment, PROVIDER_SEND_TURN_MAX_ATTACHMENTS } from "./orchestration"; | ||
| 3 | 4 | ||
| 4 | const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; | 5 | const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; |
| 5 | 6 | ||
| ... | @@ -69,6 +70,32 @@ export const GitCreateWorktreeInput = Schema.Struct({ | ... | @@ -69,6 +70,32 @@ export const GitCreateWorktreeInput = Schema.Struct({ |
| 69 | }); | 70 | }); |
| 70 | export type GitCreateWorktreeInput = typeof GitCreateWorktreeInput.Type; | 71 | export type GitCreateWorktreeInput = typeof GitCreateWorktreeInput.Type; |
| 71 | 72 | ||
| 73 | export const GitGenerateBranchNameInput = Schema.Struct({ | ||
| 74 | cwd: TrimmedNonEmptyStringSchema, | ||
| 75 | message: TrimmedNonEmptyStringSchema, | ||
| 76 | attachments: Schema.optional( | ||
| 77 | Schema.Array(ChatAttachment).check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS)), | ||
| 78 | ), | ||
| 79 | }); | ||
| 80 | export type GitGenerateBranchNameInput = typeof GitGenerateBranchNameInput.Type; | ||
| 81 | |||
| 82 | export const GitGenerateAndRenameBranchInput = Schema.Struct({ | ||
| 83 | cwd: TrimmedNonEmptyStringSchema, | ||
| 84 | oldBranch: TrimmedNonEmptyStringSchema, | ||
| 85 | message: TrimmedNonEmptyStringSchema, | ||
| 86 | attachments: Schema.optional( | ||
| 87 | Schema.Array(ChatAttachment).check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS)), | ||
| 88 | ), | ||
| 89 | }); | ||
| 90 | export type GitGenerateAndRenameBranchInput = typeof GitGenerateAndRenameBranchInput.Type; | ||
| 91 | |||
| 92 | export const GitRenameBranchInput = Schema.Struct({ | ||
| 93 | cwd: TrimmedNonEmptyStringSchema, | ||
| 94 | oldBranch: TrimmedNonEmptyStringSchema, | ||
| 95 | newBranch: TrimmedNonEmptyStringSchema, | ||
| 96 | }); | ||
| 97 | export type GitRenameBranchInput = typeof GitRenameBranchInput.Type; | ||
| 98 | |||
| 72 | export const GitRemoveWorktreeInput = Schema.Struct({ | 99 | export const GitRemoveWorktreeInput = Schema.Struct({ |
| 73 | cwd: TrimmedNonEmptyStringSchema, | 100 | cwd: TrimmedNonEmptyStringSchema, |
| 74 | path: TrimmedNonEmptyStringSchema, | 101 | path: TrimmedNonEmptyStringSchema, |
| ... | @@ -137,6 +164,21 @@ export const GitCreateWorktreeResult = Schema.Struct({ | ... | @@ -137,6 +164,21 @@ export const GitCreateWorktreeResult = Schema.Struct({ |
| 137 | }); | 164 | }); |
| 138 | export type GitCreateWorktreeResult = typeof GitCreateWorktreeResult.Type; | 165 | export type GitCreateWorktreeResult = typeof GitCreateWorktreeResult.Type; |
| 139 | 166 | ||
| 167 | export const GitGenerateBranchNameResult = Schema.Struct({ | ||
| 168 | branch: Schema.NullOr(TrimmedNonEmptyStringSchema), | ||
| 169 | }); | ||
| 170 | export type GitGenerateBranchNameResult = typeof GitGenerateBranchNameResult.Type; | ||
| 171 | |||
| 172 | export const GitGenerateAndRenameBranchResult = Schema.Struct({ | ||
| 173 | branch: Schema.NullOr(TrimmedNonEmptyStringSchema), | ||
| 174 | }); | ||
| 175 | export type GitGenerateAndRenameBranchResult = typeof GitGenerateAndRenameBranchResult.Type; | ||
| 176 | |||
| 177 | export const GitRenameBranchResult = Schema.Struct({ | ||
| 178 | branch: TrimmedNonEmptyStringSchema, | ||
| 179 | }); | ||
| 180 | export type GitRenameBranchResult = typeof GitRenameBranchResult.Type; | ||
| 181 | |||
| 140 | export const GitRunStackedActionResult = Schema.Struct({ | 182 | export const GitRunStackedActionResult = Schema.Struct({ |
| 141 | action: GitStackedAction, | 183 | action: GitStackedAction, |
| 142 | commit: Schema.Struct({ | 184 | commit: Schema.Struct({ |
packages/contracts/src/ipc.ts+11| ... | @@ -1,6 +1,12 @@ | ... | @@ -1,6 +1,12 @@ |
| 1 | import type { | 1 | import type { |
| 2 | GitCheckoutInput, | 2 | GitCheckoutInput, |
| 3 | GitCreateBranchInput, | 3 | GitCreateBranchInput, |
| 4 | GitGenerateAndRenameBranchInput, | ||
| 5 | GitGenerateAndRenameBranchResult, | ||
| 6 | GitGenerateBranchNameInput, | ||
| 7 | GitGenerateBranchNameResult, | ||
| 8 | GitRenameBranchInput, | ||
| 9 | GitRenameBranchResult, | ||
| 4 | GitCreateWorktreeInput, | 10 | GitCreateWorktreeInput, |
| 5 | GitCreateWorktreeResult, | 11 | GitCreateWorktreeResult, |
| 6 | GitInitInput, | 12 | GitInitInput, |
| ... | @@ -74,6 +80,11 @@ export interface NativeApi { | ... | @@ -74,6 +80,11 @@ export interface NativeApi { |
| 74 | // Existing branch/worktree API | 80 | // Existing branch/worktree API |
| 75 | listBranches: (input: GitListBranchesInput) => Promise<GitListBranchesResult>; | 81 | listBranches: (input: GitListBranchesInput) => Promise<GitListBranchesResult>; |
| 76 | createWorktree: (input: GitCreateWorktreeInput) => Promise<GitCreateWorktreeResult>; | 82 | createWorktree: (input: GitCreateWorktreeInput) => Promise<GitCreateWorktreeResult>; |
| 83 | generateBranchName: (input: GitGenerateBranchNameInput) => Promise<GitGenerateBranchNameResult>; | ||
| 84 | generateAndRenameBranch: ( | ||
| 85 | input: GitGenerateAndRenameBranchInput, | ||
| 86 | ) => Promise<GitGenerateAndRenameBranchResult>; | ||
| 87 | renameBranch: (input: GitRenameBranchInput) => Promise<GitRenameBranchResult>; | ||
| 77 | removeWorktree: (input: GitRemoveWorktreeInput) => Promise<void>; | 88 | removeWorktree: (input: GitRemoveWorktreeInput) => Promise<void>; |
| 78 | createBranch: (input: GitCreateBranchInput) => Promise<void>; | 89 | createBranch: (input: GitCreateBranchInput) => Promise<void>; |
| 79 | checkout: (input: GitCheckoutInput) => Promise<void>; | 90 | checkout: (input: GitCheckoutInput) => Promise<void>; |
packages/contracts/src/ws.ts+9| ... | @@ -11,11 +11,14 @@ import { | ... | @@ -11,11 +11,14 @@ import { |
| 11 | } from "./orchestration"; | 11 | } from "./orchestration"; |
| 12 | import { | 12 | import { |
| 13 | GitCheckoutInput, | 13 | GitCheckoutInput, |
| 14 | GitGenerateAndRenameBranchInput, | ||
| 14 | GitCreateBranchInput, | 15 | GitCreateBranchInput, |
| 16 | GitGenerateBranchNameInput, | ||
| 15 | GitCreateWorktreeInput, | 17 | GitCreateWorktreeInput, |
| 16 | GitInitInput, | 18 | GitInitInput, |
| 17 | GitListBranchesInput, | 19 | GitListBranchesInput, |
| 18 | GitPullInput, | 20 | GitPullInput, |
| 21 | GitRenameBranchInput, | ||
| 19 | GitRemoveWorktreeInput, | 22 | GitRemoveWorktreeInput, |
| 20 | GitRunStackedActionInput, | 23 | GitRunStackedActionInput, |
| 21 | GitStatusInput, | 24 | GitStatusInput, |
| ... | @@ -50,6 +53,9 @@ export const WS_METHODS = { | ... | @@ -50,6 +53,9 @@ export const WS_METHODS = { |
| 50 | gitRunStackedAction: "git.runStackedAction", | 53 | gitRunStackedAction: "git.runStackedAction", |
| 51 | gitListBranches: "git.listBranches", | 54 | gitListBranches: "git.listBranches", |
| 52 | gitCreateWorktree: "git.createWorktree", | 55 | gitCreateWorktree: "git.createWorktree", |
| 56 | gitGenerateBranchName: "git.generateBranchName", | ||
| 57 | gitGenerateAndRenameBranch: "git.generateAndRenameBranch", | ||
| 58 | gitRenameBranch: "git.renameBranch", | ||
| 53 | gitRemoveWorktree: "git.removeWorktree", | 59 | gitRemoveWorktree: "git.removeWorktree", |
| 54 | gitCreateBranch: "git.createBranch", | 60 | gitCreateBranch: "git.createBranch", |
| 55 | gitCheckout: "git.checkout", | 61 | gitCheckout: "git.checkout", |
| ... | @@ -111,6 +117,9 @@ export const WebSocketRequestBody = Schema.Union([ | ... | @@ -111,6 +117,9 @@ export const WebSocketRequestBody = Schema.Union([ |
| 111 | tagRequestBody(WS_METHODS.gitRunStackedAction, GitRunStackedActionInput), | 117 | tagRequestBody(WS_METHODS.gitRunStackedAction, GitRunStackedActionInput), |
| 112 | tagRequestBody(WS_METHODS.gitListBranches, GitListBranchesInput), | 118 | tagRequestBody(WS_METHODS.gitListBranches, GitListBranchesInput), |
| 113 | tagRequestBody(WS_METHODS.gitCreateWorktree, GitCreateWorktreeInput), | 119 | tagRequestBody(WS_METHODS.gitCreateWorktree, GitCreateWorktreeInput), |
| 120 | tagRequestBody(WS_METHODS.gitGenerateBranchName, GitGenerateBranchNameInput), | ||
| 121 | tagRequestBody(WS_METHODS.gitGenerateAndRenameBranch, GitGenerateAndRenameBranchInput), | ||
| 122 | tagRequestBody(WS_METHODS.gitRenameBranch, GitRenameBranchInput), | ||
| 114 | tagRequestBody(WS_METHODS.gitRemoveWorktree, GitRemoveWorktreeInput), | 123 | tagRequestBody(WS_METHODS.gitRemoveWorktree, GitRemoveWorktreeInput), |
| 115 | tagRequestBody(WS_METHODS.gitCreateBranch, GitCreateBranchInput), | 124 | tagRequestBody(WS_METHODS.gitCreateBranch, GitCreateBranchInput), |
| 116 | tagRequestBody(WS_METHODS.gitCheckout, GitCheckoutInput), | 125 | tagRequestBody(WS_METHODS.gitCheckout, GitCheckoutInput), |