- 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 behavior
Unified · Split
| @@ -21,12 +21,23 @@ function makeFakeCodexBinary(dir: string) { · 20 unmodified lines | ||
| 21 | 21 | "#!/bin/sh", |
| 22 | 22 | 'output_path=""', |
| 23 | 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 | 31 | ' if [ "$1" = "--output-last-message" ]; then', |
| 25 | 32 | " shift", |
| 26 | 33 | ' output_path="$1"', |
| 27 | 34 | " fi", |
| 28 | 35 | " shift", |
| 29 | 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 | 41 | "cat >/dev/null", |
| 31 | 42 | 'if [ -n "$T3_FAKE_CODEX_STDERR" ]; then', |
| 32 | 43 | ' printf "%s\\n" "$T3_FAKE_CODEX_STDERR" >&2', |
| @@ -48,6 +59,7 @@ function withFakeCodexEnv<A, E, R>( · 15 unmodified lines | ||
| 48 | 59 | output: string; |
| 49 | 60 | exitCode?: number; |
| 50 | 61 | stderr?: string; |
| 62 | + requireImage?: boolean; | |
| 51 | 63 | }, |
| 52 | 64 | effect: Effect.Effect<A, E, R>, |
| 53 | 65 | ) { |
| @@ -60,6 +72,7 @@ function withFakeCodexEnv<A, E, R>( · 6 unmodified lines | ||
| 60 | 72 | const previousOutput = process.env.T3_FAKE_CODEX_OUTPUT_B64; |
| 61 | 73 | const previousExitCode = process.env.T3_FAKE_CODEX_EXIT_CODE; |
| 62 | 74 | const previousStderr = process.env.T3_FAKE_CODEX_STDERR; |
| 75 | + const previousRequireImage = process.env.T3_FAKE_CODEX_REQUIRE_IMAGE; | |
| 63 | 76 | |
| 64 | 77 | yield* Effect.sync(() => { |
| 65 | 78 | process.env.PATH = `${binDir}:${previousPath ?? ""}`; |
| @@ -76,6 +89,12 @@ function withFakeCodexEnv<A, E, R>( · 10 unmodified lines | ||
| 76 | 89 | } else { |
| 77 | 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 | 100 | return { |
| @@ -83,6 +102,7 @@ function withFakeCodexEnv<A, E, R>( · 1 unmodified line | ||
| 83 | 102 | previousOutput, |
| 84 | 103 | previousExitCode, |
| 85 | 104 | previousStderr, |
| 105 | + previousRequireImage, | |
| 86 | 106 | }; |
| 87 | 107 | }), |
| 88 | 108 | () => effect, |
| @@ -107,6 +127,12 @@ function withFakeCodexEnv<A, E, R>( · 18 unmodified lines | ||
| 107 | 127 | } else { |
| 108 | 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) => { · 57 unmodified lines | ||
| 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 | 269 | it.effect("returns typed TextGenerationError when codex exits non-zero", () => |
| 174 | 270 | withFakeCodexEnv( |
| 175 | 271 | { |
| @@ -4,7 +4,10 @@ import { Effect, FileSystem, Layer, Option, Path, Schema, Stream } from "effect" · 3 unmodified lines | ||
| 4 | 4 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 5 | 5 | |
| 6 | 6 | import { TextGenerationError } from "../Errors.ts"; |
| 7 | +import { inferImageExtension, parseBase64DataUrl } from "../../imageMime.ts"; | |
| 7 | 8 | import { |
| 9 | + type BranchNameGenerationInput, | |
| 10 | + type BranchNameGenerationResult, | |
| 8 | 11 | type CommitMessageGenerationResult, |
| 9 | 12 | type PrContentGenerationResult, |
| 10 | 13 | type TextGenerationShape, |
| @@ -35,6 +38,15 @@ const PR_OUTPUT_SCHEMA_JSON = { · 24 unmodified lines | ||
| 35 | 38 | additionalProperties: false, |
| 36 | 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 | 50 | function normalizeCodexError( |
| 39 | 51 | operation: string, |
| 40 | 52 | error: unknown, |
| @@ -97,6 +109,18 @@ function parsePrOutput(raw: unknown): { title: string; body: string } { · 56 unmodified lines | ||
| 97 | 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 | 124 | function limitSection(value: string, maxChars: number): string { |
| 101 | 125 | if (value.length <= maxChars) return value; |
| 102 | 126 | const truncated = value.slice(0, maxChars); |
| @@ -124,6 +148,24 @@ function sanitizePrTitle(raw: string): string { · 21 unmodified lines | ||
| 124 | 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 | 169 | const makeCodexTextGeneration = Effect.gen(function* () { |
| 128 | 170 | const fileSystem = yield* FileSystem.FileSystem; |
| 129 | 171 | const path = yield* Path.Path; |
| @@ -168,20 +210,86 @@ const makeCodexTextGeneration = Effect.gen(function* () { · 38 unmodified lines | ||
| 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 | 237 | const safeUnlink = (filePath: string): Effect.Effect<void, never> => |
| 172 | 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 | 280 | const runCodexJson = <T>({ |
| 175 | 281 | operation, |
| 176 | 282 | cwd, |
| 177 | 283 | prompt, |
| 178 | 284 | outputSchemaJson, |
| 285 | + imagePaths = [], | |
| 179 | 286 | parse, |
| 180 | 287 | }: { |
| 181 | − operation: "generateCommitMessage" | "generatePrContent"; | |
| 288 | + operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName"; | |
| 182 | 289 | cwd: string; |
| 183 | 290 | prompt: string; |
| 184 | 291 | outputSchemaJson: object; |
| 292 | + imagePaths?: ReadonlyArray<string>; | |
| 185 | 293 | parse: (raw: unknown) => T; |
| 186 | 294 | }): Effect.Effect<T, TextGenerationError> => |
| 187 | 295 | Effect.gen(function* () { |
| @@ -208,6 +316,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { · 20 unmodified lines | ||
| 208 | 316 | schemaPath, |
| 209 | 317 | "--output-last-message", |
| 210 | 318 | outputPath, |
| 319 | + ...imagePaths.flatMap((imagePath) => ["--image", imagePath]), | |
| 211 | 320 | "-", |
| 212 | 321 | ], |
| 213 | 322 | { |
| @@ -254,9 +363,12 @@ const makeCodexTextGeneration = Effect.gen(function* () { · 40 unmodified lines | ||
| 254 | 363 | } |
| 255 | 364 | }); |
| 256 | 365 | |
| 257 | − const cleanup = Effect.all([safeUnlink(schemaPath), safeUnlink(outputPath)], { | |
| 258 | − concurrency: "unbounded", | |
| 259 | − }).pipe(Effect.asVoid); | |
| 366 | + const cleanup = Effect.all( | |
| 367 | + [schemaPath, outputPath, ...imagePaths].map((filePath) => safeUnlink(filePath)), | |
| 368 | + { | |
| 369 | + concurrency: "unbounded", | |
| 370 | + }, | |
| 371 | + ).pipe(Effect.asVoid); | |
| 260 | 372 | |
| 261 | 373 | return yield* Effect.gen(function* () { |
| 262 | 374 | yield* runCodexCommand.pipe( |
| @@ -384,9 +496,60 @@ const makeCodexTextGeneration = Effect.gen(function* () { · 121 unmodified lines | ||
| 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 | 549 | return { |
| 388 | 550 | generateCommitMessage, |
| 389 | 551 | generatePrContent, |
| 552 | + generateBranchName, | |
| 390 | 553 | } satisfies TextGenerationShape; |
| 391 | 554 | }); |
| 392 | 555 | |
| @@ -106,6 +106,7 @@ const makeIsolatedGitCore = (gitService: GitServiceShape) => · 105 unmodified lines | ||
| 106 | 106 | listBranches: (input) => core.listBranches(input), |
| 107 | 107 | createWorktree: (input) => core.createWorktree(input), |
| 108 | 108 | removeWorktree: (input) => core.removeWorktree(input), |
| 109 | + renameBranch: (input) => core.renameBranch(input), | |
| 109 | 110 | createBranch: (input) => core.createBranch(input), |
| 110 | 111 | checkoutBranch: (input) => core.checkoutBranch(input), |
| 111 | 112 | initRepo: (input) => core.initRepo(input), |
| @@ -154,6 +155,13 @@ function removeGitWorktree(input: Parameters<GitCoreShape["removeWorktree"]>[0]) · 42 unmodified lines | ||
| 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 | 165 | function pullGitBranch({ cwd }: { cwd: string }) { |
| 158 | 166 | return Effect.gen(function* () { |
| 159 | 167 | const core = yield* GitCore; |
| @@ -650,6 +658,94 @@ it.layer(TestLayer)("git integration", (it) => { · 490 unmodified lines | ||
| 650 | 658 | ); |
| 651 | 659 | }); |
| 652 | 660 | |
| 661 | + // ── renameGitBranch ── | |
| 662 | + | |
| 663 | + describe("renameGitBranch", () => { | |
| 664 | + it.effect("renames the current branch", () => | |
| 665 | + Effect.gen(function* () { | |
| 666 | + const tmp = yield* makeTmpDir(); | |
| 667 | + yield* initRepoWithCommit(tmp); | |
| 668 | + yield* createGitBranch({ cwd: tmp, branch: "feature/old-name" }); | |
| 669 | + yield* checkoutGitBranch({ cwd: tmp, branch: "feature/old-name" }); | |
| 670 | + | |
| 671 | + const renamed = yield* renameGitBranch({ | |
| 672 | + cwd: tmp, | |
| 673 | + oldBranch: "feature/old-name", | |
| 674 | + newBranch: "feature/new-name", | |
| 675 | + }); | |
| 676 | + | |
| 677 | + expect(renamed.branch).toBe("feature/new-name"); | |
| 678 | + | |
| 679 | + const branches = yield* listGitBranches({ cwd: tmp }); | |
| 680 | + expect(branches.branches.some((branch) => branch.name === "feature/old-name")).toBe(false); | |
| 681 | + const current = branches.branches.find((branch) => branch.current); | |
| 682 | + expect(current?.name).toBe("feature/new-name"); | |
| 683 | + }), | |
| 684 | + ); | |
| 685 | + | |
| 686 | + it.effect("returns success without git invocation when old/new names match", () => | |
| 687 | + Effect.gen(function* () { | |
| 688 | + const tmp = yield* makeTmpDir(); | |
| 689 | + yield* initRepoWithCommit(tmp); | |
| 690 | + const current = (yield* listGitBranches({ cwd: tmp })).branches.find((b) => b.current)!; | |
| 691 | + | |
| 692 | + const renamed = yield* renameGitBranch({ | |
| 693 | + cwd: tmp, | |
| 694 | + oldBranch: current.name, | |
| 695 | + newBranch: current.name, | |
| 696 | + }); | |
| 697 | + | |
| 698 | + expect(renamed.branch).toBe(current.name); | |
| 699 | + }), | |
| 700 | + ); | |
| 701 | + | |
| 702 | + it.effect("appends numeric suffix when target branch already exists", () => | |
| 703 | + Effect.gen(function* () { | |
| 704 | + const tmp = yield* makeTmpDir(); | |
| 705 | + yield* initRepoWithCommit(tmp); | |
| 706 | + yield* createGitBranch({ cwd: tmp, branch: "t3code/feat/session" }); | |
| 707 | + yield* createGitBranch({ cwd: tmp, branch: "t3code/tmp-working" }); | |
| 708 | + yield* checkoutGitBranch({ cwd: tmp, branch: "t3code/tmp-working" }); | |
| 709 | + | |
| 710 | + const renamed = yield* renameGitBranch({ | |
| 711 | + cwd: tmp, | |
| 712 | + oldBranch: "t3code/tmp-working", | |
| 713 | + newBranch: "t3code/feat/session", | |
| 714 | + }); | |
| 715 | + | |
| 716 | + expect(renamed.branch).toBe("t3code/feat/session-1"); | |
| 717 | + const branches = yield* listGitBranches({ cwd: tmp }); | |
| 718 | + expect(branches.branches.some((branch) => branch.name === "t3code/feat/session")).toBe( | |
| 719 | + true, | |
| 720 | + ); | |
| 721 | + expect(branches.branches.some((branch) => branch.name === "t3code/feat/session-1")).toBe( | |
| 722 | + true, | |
| 723 | + ); | |
| 724 | + const current = branches.branches.find((branch) => branch.current); | |
| 725 | + expect(current?.name).toBe("t3code/feat/session-1"); | |
| 726 | + }), | |
| 727 | + ); | |
| 728 | + | |
| 729 | + it.effect("increments suffix until it finds an available branch name", () => | |
| 730 | + Effect.gen(function* () { | |
| 731 | + const tmp = yield* makeTmpDir(); | |
| 732 | + yield* initRepoWithCommit(tmp); | |
| 733 | + yield* createGitBranch({ cwd: tmp, branch: "t3code/feat/session" }); | |
| 734 | + yield* createGitBranch({ cwd: tmp, branch: "t3code/feat/session-1" }); | |
| 735 | + yield* createGitBranch({ cwd: tmp, branch: "t3code/tmp-working" }); | |
| 736 | + yield* checkoutGitBranch({ cwd: tmp, branch: "t3code/tmp-working" }); | |
| 737 | + | |
| 738 | + const renamed = yield* renameGitBranch({ | |
| 739 | + cwd: tmp, | |
| 740 | + oldBranch: "t3code/tmp-working", | |
| 741 | + newBranch: "t3code/feat/session", | |
| 742 | + }); | |
| 743 | + | |
| 744 | + expect(renamed.branch).toBe("t3code/feat/session-2"); | |
| 745 | + }), | |
| 746 | + ); | |
| 747 | + }); | |
| 748 | + | |
| 653 | 749 | // ── createGitWorktree + removeGitWorktree ── |
| 654 | 750 | |
| 655 | 751 | describe("createGitWorktree", () => { |
| @@ -158,6 +158,43 @@ const makeGitCore = Effect.gen(function* () { · 157 unmodified lines | ||
| 158 | 158 | Effect.map((result) => result.stdout), |
| 159 | 159 | ); |
| 160 | 160 | |
| 161 | + const branchExists = (cwd: string, branch: string): Effect.Effect<boolean, GitCommandError> => | |
| 162 | + executeGit( | |
| 163 | + "GitCore.branchExists", | |
| 164 | + cwd, | |
| 165 | + ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], | |
| 166 | + { | |
| 167 | + allowNonZeroExit: true, | |
| 168 | + timeoutMs: 5_000, | |
| 169 | + }, | |
| 170 | + ).pipe(Effect.map((result) => result.code === 0)); | |
| 171 | + | |
| 172 | + const resolveAvailableBranchName = ( | |
| 173 | + cwd: string, | |
| 174 | + desiredBranch: string, | |
| 175 | + ): Effect.Effect<string, GitCommandError> => | |
| 176 | + Effect.gen(function* () { | |
| 177 | + const isDesiredTaken = yield* branchExists(cwd, desiredBranch); | |
| 178 | + if (!isDesiredTaken) { | |
| 179 | + return desiredBranch; | |
| 180 | + } | |
| 181 | + | |
| 182 | + for (let suffix = 1; suffix <= 100; suffix += 1) { | |
| 183 | + const candidate = `${desiredBranch}-${suffix}`; | |
| 184 | + const isCandidateTaken = yield* branchExists(cwd, candidate); | |
| 185 | + if (!isCandidateTaken) { | |
| 186 | + return candidate; | |
| 187 | + } | |
| 188 | + } | |
| 189 | + | |
| 190 | + return yield* createGitCommandError( | |
| 191 | + "GitCore.renameBranch", | |
| 192 | + cwd, | |
| 193 | + ["branch", "-m", desiredBranch], | |
| 194 | + `Could not find an available branch name for '${desiredBranch}'.`, | |
| 195 | + ); | |
| 196 | + }); | |
| 197 | + | |
| 161 | 198 | const resolveCurrentUpstream = ( |
| 162 | 199 | cwd: string, |
| 163 | 200 | ): Effect.Effect< |
| @@ -711,6 +748,26 @@ const makeGitCore = Effect.gen(function* () { · 547 unmodified lines | ||
| 711 | 748 | ); |
| 712 | 749 | }); |
| 713 | 750 | |
| 751 | + const renameBranch: GitCoreShape["renameBranch"] = (input) => | |
| 752 | + Effect.gen(function* () { | |
| 753 | + if (input.oldBranch === input.newBranch) { | |
| 754 | + return { branch: input.newBranch }; | |
| 755 | + } | |
| 756 | + const targetBranch = yield* resolveAvailableBranchName(input.cwd, input.newBranch); | |
| 757 | + | |
| 758 | + yield* executeGit( | |
| 759 | + "GitCore.renameBranch", | |
| 760 | + input.cwd, | |
| 761 | + ["branch", "-m", input.oldBranch, targetBranch], | |
| 762 | + { | |
| 763 | + timeoutMs: 10_000, | |
| 764 | + fallbackErrorMessage: "git branch rename failed", | |
| 765 | + }, | |
| 766 | + ); | |
| 767 | + | |
| 768 | + return { branch: targetBranch }; | |
| 769 | + }); | |
| 770 | + | |
| 714 | 771 | const createBranch: GitCoreShape["createBranch"] = (input) => |
| 715 | 772 | executeGit("GitCore.createBranch", input.cwd, ["branch", input.branch], { |
| 716 | 773 | timeoutMs: 10_000, |
| @@ -750,6 +807,7 @@ const makeGitCore = Effect.gen(function* () { · 33 unmodified lines | ||
| 750 | 807 | listBranches, |
| 751 | 808 | createWorktree, |
| 752 | 809 | removeWorktree, |
| 810 | + renameBranch, | |
| 753 | 811 | createBranch, |
| 754 | 812 | checkoutBranch, |
| 755 | 813 | initRepo, |
| @@ -41,6 +41,10 @@ interface FakeGitTextGeneration { · 40 unmodified lines | ||
| 41 | 41 | diffSummary: string; |
| 42 | 42 | diffPatch: string; |
| 43 | 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 | 50 | function makeTempDir( |
| @@ -110,6 +114,10 @@ function createTextGeneration(overrides: Partial<FakeGitTextGeneration> = {}): T · 63 unmodified lines | ||
| 110 | 114 | title: "Add stacked git actions", |
| 111 | 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 | 121 | ...overrides, |
| 114 | 122 | }; |
| 115 | 123 | |
| @@ -136,6 +144,17 @@ function createTextGeneration(overrides: Partial<FakeGitTextGeneration> = {}): T · 20 unmodified lines | ||
| 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 | |
| @@ -17,6 +17,8 @@ import type { · 16 unmodified lines | ||
| 17 | 17 | GitListBranchesInput, |
| 18 | 18 | GitListBranchesResult, |
| 19 | 19 | GitPullResult, |
| 20 | + GitRenameBranchInput, | |
| 21 | + GitRenameBranchResult, | |
| 20 | 22 | GitRemoveWorktreeInput, |
| 21 | 23 | GitStatusInput, |
| 22 | 24 | GitStatusResult, |
| @@ -124,6 +126,13 @@ export interface GitCoreShape { · 101 unmodified lines | ||
| 124 | 126 | */ |
| 125 | 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 | 137 | * Create a local branch. |
| 129 | 138 | */ |
| @@ -8,6 +8,7 @@ · 7 unmodified lines | ||
| 8 | 8 | */ |
| 9 | 9 | import { ServiceMap } from "effect"; |
| 10 | 10 | import type { Effect } from "effect"; |
| 11 | +import type { ChatAttachment } from "@t3tools/contracts"; | |
| 11 | 12 | |
| 12 | 13 | import type { TextGenerationError } from "../Errors.ts"; |
| 13 | 14 | |
| @@ -37,11 +38,22 @@ export interface PrContentGenerationResult { · 23 unmodified lines | ||
| 37 | 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 | 51 | export interface TextGenerationService { |
| 41 | 52 | generateCommitMessage( |
| 42 | 53 | input: CommitMessageGenerationInput, |
| 43 | 54 | ): Promise<CommitMessageGenerationResult>; |
| 44 | 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 { · 13 unmodified lines | ||
| 61 | 73 | readonly generatePrContent: ( |
| 62 | 74 | input: PrContentGenerationInput, |
| 63 | 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 | /** |
| @@ -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 | +} | |
| @@ -1,4 +1,3 @@ | ||
| 1 | −import Mime from "@effect/platform-node/Mime"; | |
| 2 | 1 | import { |
| 3 | 2 | ApprovalRequestId, |
| 4 | 3 | type ChatAttachment, |
| @@ -40,9 +39,8 @@ import { · 35 unmodified lines | ||
| 40 | 39 | } from "../Services/ProjectionPipeline.ts"; |
| 41 | 40 | import { |
| 42 | 41 | ATTACHMENTS_ROUTE_PREFIX, |
| 43 | − IMAGE_EXTENSION_BY_MIME_TYPE, | |
| 44 | − SAFE_IMAGE_FILE_EXTENSIONS, | |
| 45 | 42 | } from "../../projectFaviconRoute.ts"; |
| 43 | +import { inferImageExtension, parseBase64DataUrl } from "../../imageMime.ts"; | |
| 46 | 44 | |
| 47 | 45 | export const ORCHESTRATION_PROJECTOR_NAMES = { |
| 48 | 46 | projects: "projection.projects", |
| @@ -92,30 +90,6 @@ function toSafeThreadAttachmentSegment(threadId: string): string | null { · 43 unmodified lines | ||
| 92 | 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 | 93 | const materializeAttachmentsForProjection = Effect.fn(function* (input: { |
| 120 | 94 | readonly threadId: string; |
| 121 | 95 | readonly messageId: string; |
| @@ -159,8 +133,8 @@ const materializeAttachmentsForProjection = Effect.fn(function* (input: { · 37 unmodified lines | ||
| 159 | 133 | if (bytes.byteLength === 0) return attachment; |
| 160 | 134 | |
| 161 | 135 | const fileName = `${index}${inferImageExtension({ |
| 162 | − ...attachment, | |
| 163 | 136 | mimeType: parsed.mimeType, |
| 137 | + fileName: attachment.name, | |
| 164 | 138 | })}`; |
| 165 | 139 | const uniqueFileName = `${messageSegment}-${fileName}`; |
| 166 | 140 | const relativePath = `${threadSegment}/${uniqueFileName}`; |
| @@ -1,33 +1,10 @@ | ||
| 1 | 1 | import fs from "node:fs"; |
| 2 | 2 | import http from "node:http"; |
| 3 | 3 | import path from "node:path"; |
| 4 | +export { IMAGE_EXTENSION_BY_MIME_TYPE, SAFE_IMAGE_FILE_EXTENSIONS } from "./imageMime.ts"; | |
| 4 | 5 | |
| 5 | 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 | 8 | const FAVICON_MIME_TYPES: Record<string, string> = { |
| 32 | 9 | ".png": "image/png", |
| 33 | 10 | ".jpg": "image/jpeg", |
| @@ -46,6 +46,7 @@ import { Open, type OpenShape } from "./open"; · 45 unmodified lines | ||
| 46 | 46 | import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts"; |
| 47 | 47 | import type { GitCoreShape } from "./git/Services/GitCore.ts"; |
| 48 | 48 | import { GitCore } from "./git/Services/GitCore.ts"; |
| 49 | +import { TextGeneration, type TextGenerationShape } from "./git/Services/TextGeneration.ts"; | |
| 49 | 50 | import { GitCommandError, GitManagerError } from "./git/Errors.ts"; |
| 50 | 51 | import { MigrationError } from "@effect/sql-sqlite-bun/SqliteMigrator"; |
| 51 | 52 | |
| @@ -369,7 +370,11 @@ describe("WebSocket Server", () => { · 317 unmodified lines | ||
| 369 | 370 | providerLayer?: Layer.Layer<ProviderService, never>; |
| 370 | 371 | open?: OpenShape; |
| 371 | 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 | 378 | terminalManager?: TerminalManagerShape; |
| 374 | 379 | } = {}, |
| 375 | 380 | ): Promise<Http.Server> { |
| @@ -402,6 +407,9 @@ describe("WebSocket Server", () => { · 26 unmodified lines | ||
| 402 | 407 | options.gitCore |
| 403 | 408 | ? Layer.succeed(GitCore, options.gitCore as unknown as GitCoreShape) |
| 404 | 409 | : Layer.empty, |
| 410 | + options.textGeneration | |
| 411 | + ? Layer.succeed(TextGeneration, options.textGeneration as unknown as TextGenerationShape) | |
| 412 | + : Layer.empty, | |
| 405 | 413 | options.terminalManager |
| 406 | 414 | ? Layer.succeed(TerminalManager, options.terminalManager) |
| 407 | 415 | : Layer.empty, |
| @@ -1468,6 +1476,8 @@ describe("WebSocket Server", () => { · 1060 unmodified lines | ||
| 1468 | 1476 | }), |
| 1469 | 1477 | ); |
| 1470 | 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 | 1481 | const pullCurrentBranch = vi.fn(() => |
| 1472 | 1482 | Effect.fail( |
| 1473 | 1483 | new GitCommandError({ |
| @@ -1484,8 +1494,12 @@ describe("WebSocket Server", () => { · 10 unmodified lines | ||
| 1484 | 1494 | gitCore: { |
| 1485 | 1495 | listBranches, |
| 1486 | 1496 | initRepo, |
| 1497 | + renameBranch, | |
| 1487 | 1498 | pullCurrentBranch, |
| 1488 | 1499 | }, |
| 1500 | + textGeneration: { | |
| 1501 | + generateBranchName, | |
| 1502 | + }, | |
| 1489 | 1503 | }); |
| 1490 | 1504 | const addr = server.address(); |
| 1491 | 1505 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| @@ -1507,6 +1521,120 @@ describe("WebSocket Server", () => { · 15 unmodified lines | ||
| 1507 | 1521 | expect(pullResponse.result).toBeUndefined(); |
| 1508 | 1522 | expect(pullResponse.error?.message).toContain("No upstream configured"); |
| 1509 | 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 | 1640 | it("supports git.status over websocket", async () => { |
| @@ -54,6 +54,7 @@ import { clamp } from "effect/Number"; · 53 unmodified lines | ||
| 54 | 54 | import { Open } from "./open"; |
| 55 | 55 | import { ServerConfig } from "./config"; |
| 56 | 56 | import { GitCore } from "./git/Services/GitCore.ts"; |
| 57 | +import { TextGeneration } from "./git/Services/TextGeneration.ts"; | |
| 57 | 58 | import { ATTACHMENTS_ROUTE_PREFIX, tryHandleProjectFaviconRequest } from "./projectFaviconRoute"; |
| 58 | 59 | |
| 59 | 60 | /** |
| @@ -135,6 +136,31 @@ function stripRequestTag<T extends { _tag: string }>(body: T) { · 75 unmodified lines | ||
| 135 | 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 | 164 | export type ServerCoreRuntimeServices = |
| 139 | 165 | | OrchestrationEngineService |
| 140 | 166 | | ProjectionSnapshotQuery |
| @@ -146,6 +172,7 @@ export type ServerRuntimeServices = · 5 unmodified lines | ||
| 146 | 172 | | ServerCoreRuntimeServices |
| 147 | 173 | | GitManager |
| 148 | 174 | | GitCore |
| 175 | + | TextGeneration | |
| 149 | 176 | | TerminalManager |
| 150 | 177 | | Keybindings |
| 151 | 178 | | Open; |
| @@ -184,6 +211,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< · 32 unmodified lines | ||
| 184 | 211 | const terminalManager = yield* TerminalManager; |
| 185 | 212 | const keybindingsManager = yield* Keybindings; |
| 186 | 213 | const git = yield* GitCore; |
| 214 | + const textGeneration = yield* TextGeneration; | |
| 187 | 215 | const fileSystem = yield* FileSystem.FileSystem; |
| 188 | 216 | const path = yield* Path.Path; |
| 189 | 217 | |
| @@ -572,6 +600,37 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< · 382 unmodified lines | ||
| 572 | 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 | 634 | case WS_METHODS.gitRemoveWorktree: { |
| 576 | 635 | const body = stripRequestTag(request.body); |
| 577 | 636 | return yield* git.removeWorktree(body); |
| @@ -129,6 +129,7 @@ const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; · 128 unmodified lines | ||
| 129 | 129 | const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; |
| 130 | 130 | const SCRIPT_TERMINAL_COLS = 120; |
| 131 | 131 | const SCRIPT_TERMINAL_ROWS = 30; |
| 132 | +const WORKTREE_BRANCH_PREFIX = "t3code"; | |
| 132 | 133 | |
| 133 | 134 | function readLastInvokedScriptByProjectFromStorage(): Record<string, string> { |
| 134 | 135 | const stored = localStorage.getItem(LAST_INVOKED_SCRIPT_BY_PROJECT_KEY); |
| @@ -232,6 +233,57 @@ function readFileAsDataUrl(file: File): Promise<string> { · 97 unmodified lines | ||
| 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 | 287 | const VscodeEntryIcon = memo(function VscodeEntryIcon(props: { |
| 236 | 288 | pathValue: string; |
| 237 | 289 | kind: "file" | "directory"; |
| @@ -1478,6 +1530,26 @@ export default function ChatView({ threadId }: ChatViewProps) { · 1240 unmodified lines | ||
| 1478 | 1530 | const composerImagesSnapshot = [...composerImages]; |
| 1479 | 1531 | const messageIdForSend = newMessageId(); |
| 1480 | 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 | 1553 | const optimisticAttachments = composerImagesSnapshot.map((image) => ({ |
| 1482 | 1554 | type: "image" as const, |
| 1483 | 1555 | id: image.id, |
| @@ -1506,11 +1578,12 @@ export default function ChatView({ threadId }: ChatViewProps) { · 22 unmodified lines | ||
| 1506 | 1578 | setComposerHighlightedItemId(null); |
| 1507 | 1579 | |
| 1508 | 1580 | let attemptedTurnStart = false; |
| 1581 | + let pendingWorktreeBranchRename: Promise<void> | null = null; | |
| 1509 | 1582 | try { |
| 1510 | 1583 | // On first message: lock in branch + create worktree if needed. |
| 1511 | 1584 | if (baseBranchForWorktree) { |
| 1512 | 1585 | setSendPhase("preparing-worktree"); |
| 1513 | − const newBranch = `codething/${crypto.randomUUID().slice(0, 8)}`; | |
| 1586 | + const newBranch = buildTemporaryWorktreeBranchName(trimmed); | |
| 1514 | 1587 | const result = await createWorktreeMutation.mutateAsync({ |
| 1515 | 1588 | cwd: activeProject.cwd, |
| 1516 | 1589 | branch: baseBranchForWorktree, |
| @@ -1531,6 +1604,36 @@ export default function ChatView({ threadId }: ChatViewProps) { · 14 unmodified lines | ||
| 1531 | 1604 | branch: result.worktree.branch, |
| 1532 | 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 | 1637 | const setupScript = setupProjectScript(activeProject.scripts); |
| 1535 | 1638 | if (setupScript) { |
| 1536 | 1639 | await runProjectScript(setupScript, { |
| @@ -1558,25 +1661,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 21 unmodified lines | ||
| 1558 | 1661 | } |
| 1559 | 1662 | |
| 1560 | 1663 | setSendPhase("sending-turn"); |
| 1561 | − const turnAttachments = await Promise.all( | |
| 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 | − ); | |
| 1664 | + const turnAttachments = await turnAttachmentsPromise; | |
| 1580 | 1665 | attemptedTurnStart = true; |
| 1581 | 1666 | const approvalPolicy = state.runtimeMode === "full-access" ? "never" : "on-request"; |
| 1582 | 1667 | const sandboxMode = |
| @@ -1598,6 +1683,9 @@ export default function ChatView({ threadId }: ChatViewProps) { · 15 unmodified lines | ||
| 1598 | 1683 | sandboxMode, |
| 1599 | 1684 | createdAt: messageCreatedAt, |
| 1600 | 1685 | }); |
| 1686 | + if (pendingWorktreeBranchRename) { | |
| 1687 | + void pendingWorktreeBranchRename; | |
| 1688 | + } | |
| 1601 | 1689 | } catch (err) { |
| 1602 | 1690 | if ( |
| 1603 | 1691 | !attemptedTurnStart && |
| @@ -88,16 +88,16 @@ describe("getOrphanedWorktreePathForThread", () => { · 87 unmodified lines | ||
| 88 | 88 | describe("formatWorktreePathForDisplay", () => { |
| 89 | 89 | it("shows only the last path segment for unix-like paths", () => { |
| 90 | 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 | 96 | it("normalizes windows separators before selecting the final segment", () => { |
| 97 | 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 | 103 | it("uses the final segment even when outside ~/.t3/worktrees", () => { |
| @@ -165,6 +165,10 @@ export function createWsNativeApi(): NativeApi { · 164 unmodified lines | ||
| 165 | 165 | runStackedAction: (input) => transport.request(WS_METHODS.gitRunStackedAction, input), |
| 166 | 166 | listBranches: (input) => transport.request(WS_METHODS.gitListBranches, input), |
| 167 | 167 | createWorktree: (input) => transport.request(WS_METHODS.gitCreateWorktree, input), |
| 168 | + generateBranchName: (input) => transport.request(WS_METHODS.gitGenerateBranchName, input), | |
| 169 | + generateAndRenameBranch: (input) => | |
| 170 | + transport.request(WS_METHODS.gitGenerateAndRenameBranch, input), | |
| 171 | + renameBranch: (input) => transport.request(WS_METHODS.gitRenameBranch, input), | |
| 168 | 172 | removeWorktree: (input) => transport.request(WS_METHODS.gitRemoveWorktree, input), |
| 169 | 173 | createBranch: (input) => transport.request(WS_METHODS.gitCreateBranch, input), |
| 170 | 174 | checkout: (input) => transport.request(WS_METHODS.gitCheckout, input), |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import { Schema } from "effect"; |
| 2 | 2 | import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas"; |
| 3 | +import { ChatAttachment, PROVIDER_SEND_TURN_MAX_ATTACHMENTS } from "./orchestration"; | |
| 3 | 4 | |
| 4 | 5 | const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; |
| 5 | 6 | |
| @@ -67,6 +68,32 @@ export const GitCreateWorktreeInput = Schema.Struct({ · 61 unmodified lines | ||
| 67 | 68 | }); |
| 68 | 69 | export type GitCreateWorktreeInput = typeof GitCreateWorktreeInput.Type; |
| 69 | 70 | |
| 71 | +export const GitGenerateBranchNameInput = Schema.Struct({ | |
| 72 | + cwd: TrimmedNonEmptyStringSchema, | |
| 73 | + message: TrimmedNonEmptyStringSchema, | |
| 74 | + attachments: Schema.optional( | |
| 75 | + Schema.Array(ChatAttachment).check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS)), | |
| 76 | + ), | |
| 77 | +}); | |
| 78 | +export type GitGenerateBranchNameInput = typeof GitGenerateBranchNameInput.Type; | |
| 79 | + | |
| 80 | +export const GitGenerateAndRenameBranchInput = Schema.Struct({ | |
| 81 | + cwd: TrimmedNonEmptyStringSchema, | |
| 82 | + oldBranch: TrimmedNonEmptyStringSchema, | |
| 83 | + message: TrimmedNonEmptyStringSchema, | |
| 84 | + attachments: Schema.optional( | |
| 85 | + Schema.Array(ChatAttachment).check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS)), | |
| 86 | + ), | |
| 87 | +}); | |
| 88 | +export type GitGenerateAndRenameBranchInput = typeof GitGenerateAndRenameBranchInput.Type; | |
| 89 | + | |
| 90 | +export const GitRenameBranchInput = Schema.Struct({ | |
| 91 | + cwd: TrimmedNonEmptyStringSchema, | |
| 92 | + oldBranch: TrimmedNonEmptyStringSchema, | |
| 93 | + newBranch: TrimmedNonEmptyStringSchema, | |
| 94 | +}); | |
| 95 | +export type GitRenameBranchInput = typeof GitRenameBranchInput.Type; | |
| 96 | + | |
| 70 | 97 | export const GitRemoveWorktreeInput = Schema.Struct({ |
| 71 | 98 | cwd: TrimmedNonEmptyStringSchema, |
| 72 | 99 | path: TrimmedNonEmptyStringSchema, |
| @@ -135,6 +162,21 @@ export const GitCreateWorktreeResult = Schema.Struct({ · 62 unmodified lines | ||
| 135 | 162 | }); |
| 136 | 163 | export type GitCreateWorktreeResult = typeof GitCreateWorktreeResult.Type; |
| 137 | 164 | |
| 165 | +export const GitGenerateBranchNameResult = Schema.Struct({ | |
| 166 | + branch: Schema.NullOr(TrimmedNonEmptyStringSchema), | |
| 167 | +}); | |
| 168 | +export type GitGenerateBranchNameResult = typeof GitGenerateBranchNameResult.Type; | |
| 169 | + | |
| 170 | +export const GitGenerateAndRenameBranchResult = Schema.Struct({ | |
| 171 | + branch: Schema.NullOr(TrimmedNonEmptyStringSchema), | |
| 172 | +}); | |
| 173 | +export type GitGenerateAndRenameBranchResult = typeof GitGenerateAndRenameBranchResult.Type; | |
| 174 | + | |
| 175 | +export const GitRenameBranchResult = Schema.Struct({ | |
| 176 | + branch: TrimmedNonEmptyStringSchema, | |
| 177 | +}); | |
| 178 | +export type GitRenameBranchResult = typeof GitRenameBranchResult.Type; | |
| 179 | + | |
| 138 | 180 | export const GitRunStackedActionResult = Schema.Struct({ |
| 139 | 181 | action: GitStackedAction, |
| 140 | 182 | commit: Schema.Struct({ |
| @@ -1,6 +1,12 @@ | ||
| 1 | 1 | import type { |
| 2 | 2 | GitCheckoutInput, |
| 3 | 3 | GitCreateBranchInput, |
| 4 | + GitGenerateAndRenameBranchInput, | |
| 5 | + GitGenerateAndRenameBranchResult, | |
| 6 | + GitGenerateBranchNameInput, | |
| 7 | + GitGenerateBranchNameResult, | |
| 8 | + GitRenameBranchInput, | |
| 9 | + GitRenameBranchResult, | |
| 4 | 10 | GitCreateWorktreeInput, |
| 5 | 11 | GitCreateWorktreeResult, |
| 6 | 12 | GitInitInput, |
| @@ -74,6 +80,11 @@ export interface NativeApi { · 67 unmodified lines | ||
| 74 | 80 | // Existing branch/worktree API |
| 75 | 81 | listBranches: (input: GitListBranchesInput) => Promise<GitListBranchesResult>; |
| 76 | 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 | 88 | removeWorktree: (input: GitRemoveWorktreeInput) => Promise<void>; |
| 78 | 89 | createBranch: (input: GitCreateBranchInput) => Promise<void>; |
| 79 | 90 | checkout: (input: GitCheckoutInput) => Promise<void>; |
| @@ -11,11 +11,14 @@ import { · 10 unmodified lines | ||
| 11 | 11 | } from "./orchestration"; |
| 12 | 12 | import { |
| 13 | 13 | GitCheckoutInput, |
| 14 | + GitGenerateAndRenameBranchInput, | |
| 14 | 15 | GitCreateBranchInput, |
| 16 | + GitGenerateBranchNameInput, | |
| 15 | 17 | GitCreateWorktreeInput, |
| 16 | 18 | GitInitInput, |
| 17 | 19 | GitListBranchesInput, |
| 18 | 20 | GitPullInput, |
| 21 | + GitRenameBranchInput, | |
| 19 | 22 | GitRemoveWorktreeInput, |
| 20 | 23 | GitRunStackedActionInput, |
| 21 | 24 | GitStatusInput, |
| @@ -50,6 +53,9 @@ export const WS_METHODS = { · 28 unmodified lines | ||
| 50 | 53 | gitRunStackedAction: "git.runStackedAction", |
| 51 | 54 | gitListBranches: "git.listBranches", |
| 52 | 55 | gitCreateWorktree: "git.createWorktree", |
| 56 | + gitGenerateBranchName: "git.generateBranchName", | |
| 57 | + gitGenerateAndRenameBranch: "git.generateAndRenameBranch", | |
| 58 | + gitRenameBranch: "git.renameBranch", | |
| 53 | 59 | gitRemoveWorktree: "git.removeWorktree", |
| 54 | 60 | gitCreateBranch: "git.createBranch", |
| 55 | 61 | gitCheckout: "git.checkout", |
| @@ -111,6 +117,9 @@ export const WebSocketRequestBody = Schema.Union([ · 55 unmodified lines | ||
| 111 | 117 | tagRequestBody(WS_METHODS.gitRunStackedAction, GitRunStackedActionInput), |
| 112 | 118 | tagRequestBody(WS_METHODS.gitListBranches, GitListBranchesInput), |
| 113 | 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 | 123 | tagRequestBody(WS_METHODS.gitRemoveWorktree, GitRemoveWorktreeInput), |
| 115 | 124 | tagRequestBody(WS_METHODS.gitCreateBranch, GitCreateBranchInput), |
| 116 | 125 | tagRequestBody(WS_METHODS.gitCheckout, GitCheckoutInput), |